diff --git a/.github/scripts/test_ssh_host_trust.py b/.github/scripts/test_ssh_host_trust.py new file mode 100644 index 000000000..1213155c5 --- /dev/null +++ b/.github/scripts/test_ssh_host_trust.py @@ -0,0 +1,143 @@ +"""Offline tests of the actual deployment host-trust shell step.""" + +from pathlib import Path +import os +import re +import shutil +import subprocess +import tempfile +import textwrap +import unittest + +ROOT = Path(__file__).resolve().parents[2] +WORKFLOW = ROOT / ".github/workflows/deploy-vps.yml" +DEPLOYMENT = "web" + + +def shell_path(path): + value = Path(path).as_posix() + if os.name == "nt" and len(value) > 1 and value[1] == ":": + return "/" + value[0].lower() + value[2:] + return value + + +def executable(name): + if os.name == "nt": + candidate = Path("C:/Program Files/Git/usr/bin") / (name + ".exe") + if candidate.is_file(): + return str(candidate) + result = shutil.which(name) + if not result: + raise RuntimeError(name + " is required for host-trust regressions") + return result + + +class ReviewedSshHostTrustTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.temporary = tempfile.TemporaryDirectory(prefix="fireguard ssh trust ") + cls.directory = Path(cls.temporary.name) + cls.shell = executable("bash") + cls.keygen = executable("ssh-keygen") + cls.key = cls.directory / "fixture-key" + subprocess.run( + [cls.keygen, "-q", "-t", "ed25519", "-N", "", "-f", shell_path(cls.key)], + check=True, capture_output=True, + ) + cls.public_key = (cls.directory / "fixture-key.pub").read_text().strip() + workflow = WORKFLOW.read_text() + step = workflow.split(" - name: Configure reviewed SSH host trust\n", 1)[1] + step = step.split(" - name: ", 1)[0] + cls.script = textwrap.dedent(step.split(" run: |\n", 1)[1]) + + @classmethod + def tearDownClass(cls): + cls.temporary.cleanup() + + def run_step(self, material, host="vps.example.invalid", port="22"): + with tempfile.TemporaryDirectory(dir=self.directory) as directory: + temporary = Path(directory) + environment = os.environ.copy() + environment.update({ + "VPS_HOST": host, + "VPS_PORT": port, + "VPS_SSH_KNOWN_HOSTS": material, + "RUNNER_TEMP": shell_path(temporary), + "GITHUB_ENV": shell_path(temporary / "github-env"), + }) + environment["PATH"] = str(Path(self.keygen).parent) + os.pathsep + environment["PATH"] + result = subprocess.run( + [self.shell, "-c", self.script], env=environment, + capture_output=True, text=True, timeout=10, + ) + installed = temporary / ("fireguard-" + DEPLOYMENT + "-known_hosts") + saved = installed.read_text() if installed.exists() else None + canonical = (temporary / "github-env").read_text() if (temporary / "github-env").exists() else None + return result, saved, canonical + + def test_approved_hosts_and_ports(self): + cases = [ + ("vps.example.invalid", "22", "vps.example.invalid"), + ("192.0.2.10", "22", "192.0.2.10"), + ("2001:db8::1", "22", "2001:db8::1"), + ("vps.example.invalid", "2222", "[vps.example.invalid]:2222"), + ("2001:db8::1", "2222", "[2001:db8::1]:2222"), + ("vps.example.invalid", "00022", "vps.example.invalid"), + ] + for host, port, lookup in cases: + with self.subTest(host=host, port=port): + material = lookup + " " + self.public_key + result, installed, canonical = self.run_step(material, host, port) + self.assertEqual(0, result.returncode, result.stderr + result.stdout) + self.assertEqual(material + "\n", installed) + self.assertEqual("VPS_PORT=" + str(int(port)) + "\n", canonical) + + def test_missing_blank_unrelated_and_wrong_port_are_rejected(self): + for material in ["", " \n\t", "other.example.invalid " + self.public_key, + "[vps.example.invalid]:2222 " + self.public_key]: + with self.subTest(material=material[:24]): + result, _, canonical = self.run_step(material) + self.assertNotEqual(0, result.returncode) + self.assertIsNone(canonical) + + def test_malformed_matching_key_is_rejected(self): + result, _, canonical = self.run_step("vps.example.invalid ssh-ed25519 not-a-key") + self.assertNotEqual(0, result.returncode) + self.assertIsNone(canonical) + + def test_invalid_ports_are_rejected(self): + for port in ["", "0", "65536", "100000", "-1", "22x"]: + with self.subTest(port=port): + result, _, canonical = self.run_step("vps.example.invalid " + self.public_key, port=port) + self.assertNotEqual(0, result.returncode) + self.assertIsNone(canonical) + + def test_hashed_host_and_approved_aliases_are_preserved(self): + entry = self.directory / "hashed-hosts" + entry.write_text("vps.example.invalid " + self.public_key + "\n") + subprocess.run([self.keygen, "-H", "-f", shell_path(entry)], check=True, capture_output=True) + material = entry.read_text().strip() + result, installed, _ = self.run_step(material) + self.assertEqual(0, result.returncode, result.stderr + result.stdout) + self.assertEqual(material + "\n", installed) + aliases = "vps.example.invalid,192.0.2.10 " + self.public_key + result, installed, _ = self.run_step(aliases) + self.assertEqual(0, result.returncode, result.stderr + result.stdout) + self.assertEqual(aliases + "\n", installed) + + def test_workflow_requires_independent_trust_and_strict_handshake(self): + workflow = WORKFLOW.read_text() + self.assertNotIn("ssh-keyscan", workflow) + self.assertIn("secrets.VPS_SSH_KNOWN_HOSTS", workflow) + self.assertIn("StrictHostKeyChecking=yes", workflow) + self.assertIn("GlobalKnownHostsFile=/dev/null", workflow) + self.assertRegex(workflow, r"ANSIBLE_HOST_KEY_CHECKING[^\n]*(?:true|True)") + self.assertLess(workflow.index("Configure reviewed SSH host trust"), workflow.index("ansible-playbook")) + if DEPLOYMENT == "api": + self.assertIn('UserKnownHostsFile=\\\"$RUNNER_TEMP/fireguard-api-known_hosts\\\"', workflow) + else: + self.assertIn("/fireguard-web-known_hosts", workflow) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f5c045953..53dd8cc9b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,6 +76,8 @@ jobs: node-version: ${{ env.NODE_VERSION }} - name: Test deployment validation run: node --test .github/scripts/*.test.mjs + - name: Test reviewed SSH host trust + run: python3 -B .github/scripts/test_ssh_host_trust.py - name: Require immutable action pins run: python3 -B .github/scripts/check-action-pins.py - name: Test immutable API reference validation diff --git a/.github/workflows/deploy-vps.yml b/.github/workflows/deploy-vps.yml index 53e03ba03..67e0a9b87 100644 --- a/.github/workflows/deploy-vps.yml +++ b/.github/workflows/deploy-vps.yml @@ -111,6 +111,8 @@ jobs: VPS_HOST: ${{ secrets.VPS_HOST }} VPS_USER: ${{ secrets.VPS_USER }} VPS_SSH_KEY: ${{ secrets.VPS_SSH_KEY }} + VPS_SSH_KNOWN_HOSTS: ${{ secrets.VPS_SSH_KNOWN_HOSTS }} + ANSIBLE_HOST_KEY_CHECKING: 'true' VPS_PORT: ${{ vars.VPS_PORT || '22' }} steps: @@ -124,7 +126,7 @@ jobs: *) echo "Unsupported deployment branch: $GITHUB_REF_NAME"; exit 1 ;; esac - for value in APP_DIR APP_HOST APP_API_URL APP_MERCURE_HUB_URL COMPOSE_PROJECT_NAME CONTAINER_NAME TRAEFIK_ROUTER_NAME VPS_HOST VPS_USER VPS_SSH_KEY; do + for value in APP_DIR APP_HOST APP_API_URL APP_MERCURE_HUB_URL COMPOSE_PROJECT_NAME CONTAINER_NAME TRAEFIK_ROUTER_NAME VPS_HOST VPS_USER VPS_SSH_KEY VPS_SSH_KNOWN_HOSTS; do test -n "${!value}" || { echo "$value is required."; exit 1; } done @@ -155,6 +157,34 @@ jobs: python -m pip install -r deployment/ansible/requirements.txt ansible-galaxy collection install -r deployment/ansible/requirements.yml + - name: Configure reviewed SSH host trust + shell: bash + run: | + set -eu + known_hosts="$RUNNER_TEMP/fireguard-web-known_hosts" + matching_keys="$known_hosts.match" + trap 'rm -f "$matching_keys"' EXIT + case "$VPS_PORT" in + ''|*[!0-9]*) echo "VPS_PORT must be a valid port."; exit 1 ;; + esac + if [ "${#VPS_PORT}" -gt 5 ]; then + echo "VPS_PORT must be a valid port."; exit 1 + fi + port=$((10#$VPS_PORT)) + if [ "$port" -lt 1 ] || [ "$port" -gt 65535 ]; then + echo "VPS_PORT must be a valid port."; exit 1 + fi + test -n "$VPS_SSH_KNOWN_HOSTS" || { echo "VPS_SSH_KNOWN_HOSTS is required."; exit 1; } + printf '%s\n' "$VPS_SSH_KNOWN_HOSTS" > "$known_hosts" + chmod 600 "$known_hosts" + host_lookup="$VPS_HOST" + if [ "$port" -ne 22 ]; then host_lookup="[$VPS_HOST]:$port"; fi + ssh-keygen -F "$host_lookup" -f "$known_hosts" > "$matching_keys" || + { echo "No approved SSH host key matches the deployment target."; exit 1; } + ssh-keygen -l -f "$matching_keys" > /dev/null 2>&1 || + { echo "The approved SSH host key material is invalid."; exit 1; } + printf 'VPS_PORT=%s\n' "$port" >> "$GITHUB_ENV" + - name: Prepare SSH and Ansible inventory shell: bash run: | @@ -185,11 +215,10 @@ jobs: "ansible_port": int(port), "ansible_python_interpreter": "/usr/bin/python3", "ansible_ssh_private_key_file": str(directory / "private-key"), - "ansible_ssh_common_args": f"-o UserKnownHostsFile={directory / 'known_hosts'} -o StrictHostKeyChecking=yes", + "ansible_ssh_common_args": f"-o UserKnownHostsFile={os.environ['RUNNER_TEMP']}/fireguard-web-known_hosts -o GlobalKnownHostsFile=/dev/null -o StrictHostKeyChecking=yes", }}}}}} (directory / "inventory.json").write_text(json.dumps(inventory)) PY - ssh-keyscan -p "$VPS_PORT" "$VPS_HOST" > "$directory/known_hosts" - name: Deploy the verified image with Ansible env: diff --git a/.oxlintrc.json b/.oxlintrc.json index ca4560cc2..5523b8936 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -176,6 +176,7 @@ "!@features/organization/models", "!@features/organization/data-access", "!@features/organization/utils", + "!@features/organization/services/organization-landing", "!@features/organization/features/equipments" ], "message": "ARCHITECTURE.md 4 / 14.2 - onboarding may consume the documented organization surfaces, and they are the ones its FEATURE.md records: setup, models, data-access, utils, and the equipments public API. Anything deeper is a private path. This rule mirrors the documented contract - widen the FEATURE.md first, then this list." @@ -261,7 +262,10 @@ "!@features/organization/features/equipments/data-access/**", "!@features/organization/features/equipments/models", "!@features/organization/features/equipments/models/**", - "!@features/organization/features/equipments/ui/components/equipment-status-tag" + "!@features/organization/features/equipments/ui/components", + "!@features/organization/features/equipments/state", + "!@features/organization/features/equipments/options", + "!@features/organization/features/equipments/utils" ], "message": "ARCHITECTURE.md 4 / 14.2 - the assets explorer may consume the equipments subfeature only through its data-access and models barrels (EquipmentService, EquipmentOutput) and the equipment-status-tag component, the surfaces organization/FEATURE.md records for this dependency. Anything deeper is a private path. This rule mirrors the documented contract - widen the FEATURE.md first, then this list." }, @@ -272,7 +276,8 @@ "!@features/organization/features/inspections/data-access/**", "!@features/organization/features/inspections/models", "!@features/organization/features/inspections/models/**", - "!@features/organization/features/inspections/ui/components/inspection-status-tag" + "!@features/organization/features/inspections/ui/components/inspection-status-tag", + "!@features/organization/features/inspections/ui/dataviews/non-conformity-list" ], "message": "ARCHITECTURE.md 4 / 14.2 - the assets explorer may consume the inspections subfeature only through its data-access and models barrels (InspectionService, InspectionOutput) and the inspection-status-tag component, the surfaces organization/FEATURE.md records for this dependency. Anything deeper is a private path. This rule mirrors the documented contract - widen the FEATURE.md first, then this list." } diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 0cfb88af6..d19f4f587 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -47,7 +47,17 @@ Required secrets: - `VPS_HOST` - `VPS_USER` - `VPS_SSH_KEY` +- `VPS_SSH_KNOWN_HOSTS`: complete approved OpenSSH known_hosts entries, verified + through an authenticated operator/provider channel for this environment - `GHCR_TOKEN` if the workflow token is insufficient + +SSH deployment fails before Ansible if the reviewed host-key secret is missing, +invalid or does not match the host and port. Port 22 uses the bare host; custom +ports use `[host]:port`, including IPv6. Hashed entries and multiple approved keys +are supported. Strict checking uses only that file, without a network-scan trust +fallback. Independently verify host-key rotations and update the environment +secret before redeploying or rolling back an image. + - `BASIC_AUTH_USERS` in `development`, in the htpasswd format accepted by Traefik - `BASIC_AUTH_CREDENTIALS` in `development`, as `username:password`, only for the public health check diff --git a/PRODUCT.md b/PRODUCT.md index 2cd2dfdd8..bed56cef1 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -19,25 +19,30 @@ workspace. Two primary contexts: offline (IndexedDB persistence + outbox replay). They need fast, thumb-reachable actions and a clear "next recommended action". -The job to be done: take a fire-safety intervention from draft → planned → -executed in the field → reviewed → published, without losing data offline and -without ambiguity about what to do next. +The job to be done: know the fire-equipment park, organize its controls and +maintenance, and preserve the record of every intervention. Service providers +select their internal customer and sites; operators manage their own sites +without a mandatory customer record. Both use the same authorized workflows. ## Product Purpose -Fireguard is an organization-scoped platform for planning, executing and -publishing field interventions (facilities, equipment, inspections). Success is -an intervention that moves through its full workflow with zero data loss -offline, clear blocker resolution, and an atomic publication that either fully -succeeds or leaves records untouched. +FireGuard is a specialized maintenance management application for fire-equipment +parks. Its promise is: "Know your fire-equipment park, organize its controls and +maintenance, and preserve the record of every intervention." Fire safety leads +the initial views and workflows; emergency lighting, gas detection, access +control and cameras are adjacent equipment families in the same park. ## Positioning -**The headline claim is atomic offline publication.** The agent works fully -offline; publication then applies every inspection, equipment change and record -as one transaction that either fully succeeds or leaves records untouched. -There is no half-published intervention. This leads because it is the hardest -mechanism for a neighboring product to copy truthfully. +**The headline is fire-equipment park management.** Park is the primary entry +point. The equipment dossier distinguishes declared operational status, control +deadlines and unresolved anomalies. A performed unfavorable control remains a +performed control; its anomalies remain open. A failed repair remains necessary. + +Atomic publication and durable offline execution support this promise: publication +applies inspections, equipment changes and validated work results in one transaction. +A validated dossier preserves its customer/site identity, evidence and execution +facts at publication, while later corrections remain separately traceable. Two further mechanisms are equally true and support it, but do not lead: @@ -52,6 +57,22 @@ Two further mechanisms are equally true and support it, but do not lead: ## Operating Context +- **Park context** starts with fire equipment and offers an explicit all-equipment + view. Customer, site and action-queue filters are shared by exact server counts + and their destination lists. Operational unavailability, upcoming controls and + unresolved anomalies form the three principal action queues. +- **Equipment identity** includes an organization-unique asset reference, + descriptive name, declared criticality and simple value/unit characteristics. + Organization-owned catalogue types complement preserved historical codes. + Replacement retires the predecessor and links one successor idempotently; + historical controls, documents and QR identification remain attached to the old item. +- **Independent preventive operations** have original due dates and explicit + validated results. Control and servicing calendars are independent; fixed + calendars retain their anchor across short months. Historical calendars remain + compatible until the organization explicitly switches its single active engine. +- **Organization profile** adapts starting views and onboarding. Effective API + permissions determine rights; the profile never grants permissions or shares + customers between organizations. - **The intervention lifecycle** is the spine of the product: `draft → planned → in_progress → submitted (review) → published`, plus `changes_requested` (reviewer sends work back with a required note) and @@ -108,6 +129,23 @@ Two further mechanisms are equally true and support it, but do not lead: **Constraints future work must preserve** +- FireGuard retains its name. Brand replacement, customer portals, collaboration + between organizations, meters, sensors and a dedicated ERP connector are later + projects. General building maintenance and industry are outside this trajectory. +- Organization-defined intervals and checklists provide operational tracking; + they do not constitute automatic regulatory certification. +- Repair requests, interventions, quantitative parts, warehouses, suppliers, + orders, partial receipts, returns, time and internal costs form one maintenance + chain. Unresolved physical declarations stay traceable and block publication. + Individual equipment in reserve remains separate from quantitative stock. +- Financial reports allocate each contribution once to equipment, site or customer, + with explicit unallocated and unknown costs. Current realized costs remain distinct + from the snapshot frozen at publication and from planned amounts. +- Versioned JSON/CSV maintenance exports preserve their original bytes and stable + references. A later correction creates a linked adjustment. Export generation and + confirmed ERP import are separate states, and financial inclusion requires its + own permission. Commercial quotes, sales prices, invoices, taxes and payments + remain in the external ERP. `Billing` owns the FireGuard SaaS subscription. - **Market frame: EU multi-market.** No single national fire-safety regime may be presumed in the domain model, and no copy may claim conformity with a specific standard (ERP registre de sécurité, APSAD, NF, or any other). diff --git a/README.md b/README.md index 948d5a42a..69b18f825 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,8 @@ # Fireguard Web -Angular frontend for Fireguard: organization-scoped fire-safety work, field -interventions, offline workflows and collaboration. It uses the +Angular frontend for FireGuard fire-equipment park management: controls, +maintenance, field interventions, offline workflows, parts and purchasing, +private economic reports and retained ERP exports. It uses the [Fireguard API](https://github.com/DevSkyLex/fireguard-api). ## Tech stack diff --git a/package-lock.json b/package-lock.json index 3388a9470..d85265728 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15867,9 +15867,9 @@ } }, "node_modules/smol-toml": { - "version": "1.7.1", - "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.7.1.tgz", - "integrity": "sha512-PPlsspAZ4jbMBu5DMFhfUGDQLu/vrL4SyBROVS37x8ynnVmFIs1VPBz1Co8Xks3TvpIaZXmU85y4DrQ+UyVFoQ==", + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.9.0.tgz", + "integrity": "sha512-hpd+HLON7HdZXqYchMM/+LaTTbdK0AU3NngIJ4KVyWbY9bfQqdL9cD+4yf6dUoU2Ap4VsU0JkQi6FxAI1B2mXQ==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -16171,9 +16171,9 @@ } }, "node_modules/tinypool": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-2.1.0.tgz", - "integrity": "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-2.1.2.tgz", + "integrity": "sha512-9YodfrxS9g9IbFr/KOjE5bAeJ0p61n3bW6mqvy0jtoeKd1kTW1Cxm0oulm6KX2lyM9Gl6WIe8nEbY7LWv5ZJww==", "dev": true, "license": "MIT", "engines": { diff --git a/package.json b/package.json index 7409d7162..a99cdf8b2 100644 --- a/package.json +++ b/package.json @@ -120,7 +120,10 @@ "nx": { "axios": "1.20.0", "brace-expansion": "5.0.12", - "smol-toml": "1.7.1" + "smol-toml": "1.9.0" + }, + "oxfmt": { + "tinypool": "2.1.2" } }, "packageManager": "npm@11.16.0" diff --git a/src/app/core/indexed-db/services/indexed-db/indexed-db.service.ts b/src/app/core/indexed-db/services/indexed-db/indexed-db.service.ts index 18a643b04..9852e7337 100644 --- a/src/app/core/indexed-db/services/indexed-db/indexed-db.service.ts +++ b/src/app/core/indexed-db/services/indexed-db/indexed-db.service.ts @@ -312,6 +312,93 @@ export abstract class IndexedDbService { }); } + /** + * Method updateTransaction + * @method updateTransaction + * + * @description + * Reads and derives writes in one readwrite transaction, serialized by IndexedDB across + * connections and tabs. The synchronous updater must return writes only for the declared stores; + * it cannot await work while the native transaction is active. + * + * @access public + * @since unreleased + * + * @param {Readonly>} reads - Stores and record keys to read and + * lock together, including any ownership or workspace records required by the mutation. + * @param {( + * values: Readonly>>>, + * ) => Readonly[]>>} update + * - Synchronous mutation over committed values; missing records are null. Throwing aborts every + * write. + * @param {(() => boolean) | undefined} isCurrent - Optional owner/session guard checked before + * opening the transaction, before deriving writes and after commit. + * + * @returns {Promise} Resolves after all derived writes commit. + */ + public async updateTransaction( + reads: Readonly>, + update: ( + values: Readonly>>>, + ) => Readonly[]>>, + isCurrent?: () => boolean, + ): Promise { + if (!this.browser) return; + const storeNames = Object.keys(reads); + if (storeNames.length === 0) return; + const database = await this.open(); + if (isCurrent && !isCurrent()) + throw new DOMException('Offline operation ownership changed.', 'AbortError'); + + await new Promise((resolve, reject) => { + const transaction = database.transaction(storeNames, 'readwrite'); + const values = Object.create(null) as Record>; + let remaining = Object.values(reads).reduce((total, keys) => total + keys.length, 0); + const mutate = (): void => { + try { + if (isCurrent && !isCurrent()) + throw new DOMException('Offline operation ownership changed.', 'AbortError'); + const writes = update(values); + for (const [storeName, entries] of Object.entries(writes)) { + const store = transaction.objectStore(storeName); + for (const entry of entries) store.put(entry.value, entry.key); + } + } catch (error: unknown) { + reject(error); + transaction.abort(); + } + }; + transaction.addEventListener('complete', () => { + if (isCurrent && !isCurrent()) + reject(new DOMException('Offline operation ownership changed.', 'AbortError')); + else resolve(); + }); + transaction.addEventListener('abort', () => + reject(transaction.error ?? new Error('IndexedDB transaction aborted')), + ); + transaction.addEventListener('error', () => + reject(transaction.error ?? new Error('IndexedDB transaction failed')), + ); + for (const storeName of storeNames) { + const records = Object.create(null) as Record; + values[storeName] = records; + const store = transaction.objectStore(storeName); + for (const key of reads[storeName] ?? []) { + const request = store.get(key); + request.addEventListener('success', () => { + records[key] = request.result ?? null; + remaining -= 1; + if (remaining === 0) mutate(); + }); + request.addEventListener('error', () => + reject(request.error ?? new Error('IndexedDB request failed')), + ); + } + } + if (remaining === 0) mutate(); + }); + } + /** * Method get * @method get diff --git a/src/app/core/indexed-db/services/indexed-db/testing/indexed-db.service.spec.ts b/src/app/core/indexed-db/services/indexed-db/testing/indexed-db.service.spec.ts index 8c31173b5..b99c79cf1 100644 --- a/src/app/core/indexed-db/services/indexed-db/testing/indexed-db.service.spec.ts +++ b/src/app/core/indexed-db/services/indexed-db/testing/indexed-db.service.spec.ts @@ -164,6 +164,9 @@ describe('IndexedDbService', () => { await expect( service.putTransaction({ outbox: [{ key: 'k', value: {} }] }), ).resolves.toBeUndefined(); + await expect( + service.updateTransaction({ outbox: ['k'] }, () => ({})), + ).resolves.toBeUndefined(); await expect(service.remove('outbox', 'k')).resolves.toBeUndefined(); await expect(service.removeWhere('outbox', () => true)).resolves.toBeUndefined(); await expect(service.clearAll()).resolves.toBeUndefined(); @@ -366,6 +369,7 @@ describe('IndexedDbService', () => { transaction = Object.assign(new EventTarget(), { objectStore: vi.fn(() => store), error: null, + abort: vi.fn(), }) as unknown as IDBTransaction; database = Object.assign(new EventTarget(), { transaction: vi.fn(() => transaction), @@ -600,6 +604,102 @@ describe('IndexedDbService', () => { await expect(result).resolves.toBeUndefined(); }); + it('derives writes from every read in the same transaction and waits for commit', async () => { + const ownerRead = browserRequest('account-1'); + const previousRead = browserRequest({ count: 1 }); + const workspaceRead = browserRequest({ organization: 'org-1' }); + const metadata = { + ...store, + get: vi.fn((key: string) => (key === 'ownerUserId' ? ownerRead : previousRead)), + put: vi.fn(), + }; + const outbox = { ...store, get: vi.fn(() => workspaceRead) }; + vi.mocked(transaction.objectStore).mockImplementation( + (name: string) => (name === 'metadata' ? metadata : outbox) as unknown as IDBObjectStore, + ); + const update = vi.fn(() => ({ metadata: [{ key: 'snapshot', value: { count: 2 } }] })); + const write = service.updateTransaction( + { metadata: ['ownerUserId', 'snapshot'], outbox: ['workspace'] }, + update, + ); + const settled = vi.fn(); + void write.then(settled); + await opened(); + expect(database.transaction).toHaveBeenCalledExactlyOnceWith( + ['metadata', 'outbox'], + 'readwrite', + ); + ownerRead.dispatchEvent(new Event('success')); + previousRead.dispatchEvent(new Event('success')); + expect(update).not.toHaveBeenCalled(); + workspaceRead.dispatchEvent(new Event('success')); + expect(update).toHaveBeenCalledExactlyOnceWith({ + metadata: { ownerUserId: 'account-1', snapshot: { count: 1 } }, + outbox: { workspace: { organization: 'org-1' } }, + }); + expect(metadata.put).toHaveBeenCalledExactlyOnceWith({ count: 2 }, 'snapshot'); + await Promise.resolve(); + expect(settled).not.toHaveBeenCalled(); + transaction.dispatchEvent(new Event('complete')); + await expect(write).resolves.toBeUndefined(); + }); + + it('aborts derived writes when scope changes while transaction reads are pending', async () => { + let current = true; + const update = vi.fn(() => ({ metadata: [{ key: 'snapshot', value: {} }] })); + const write = service.updateTransaction({ metadata: ['snapshot'] }, update, () => current); + const rejected = expect(write).rejects.toMatchObject({ name: 'AbortError' }); + await opened(); + current = false; + valueRequest.dispatchEvent(new Event('success')); + await rejected; + expect(update).not.toHaveBeenCalled(); + expect(store.put).not.toHaveBeenCalled(); + expect(transaction.abort).toHaveBeenCalledOnce(); + }); + + it('aborts the transaction and preserves the updater failure', async () => { + const failure = new Error('Invalid saved workspace'); + const write = service.updateTransaction({ metadata: ['snapshot'] }, () => { + throw failure; + }); + const rejected = expect(write).rejects.toBe(failure); + await opened(); + valueRequest.dispatchEvent(new Event('success')); + await rejected; + expect(store.put).not.toHaveBeenCalled(); + expect(transaction.abort).toHaveBeenCalledOnce(); + }); + + it.each(['abort', 'error'])( + 'does not acknowledge an atomic update when its transaction reports %s', + async (event) => { + const failure = new DOMException('Device storage full', 'QuotaExceededError'); + const write = service.updateTransaction({ metadata: ['snapshot'] }, () => ({ + metadata: [{ key: 'snapshot', value: {} }], + })); + const rejected = expect(write).rejects.toBe(failure); + await opened(); + valueRequest.dispatchEvent(new Event('success')); + Object.assign(transaction, { error: failure }); + transaction.dispatchEvent(new Event(event)); + await rejected; + }, + ); + + it('propagates an atomic update read failure before running its updater', async () => { + const failure = new DOMException('Storage unavailable', 'UnknownError'); + const update = vi.fn(() => ({})); + const write = service.updateTransaction({ metadata: ['snapshot'] }, update); + const rejected = expect(write).rejects.toBe(failure); + await opened(); + Object.assign(valueRequest, { error: failure }); + valueRequest.dispatchEvent(new Event('error')); + await rejected; + expect(update).not.toHaveBeenCalled(); + expect(store.put).not.toHaveBeenCalled(); + }); + it('removes only matching cursor entries and completes after the transaction', async () => { const predicate = vi.fn( (value: { synced: boolean }, key: IDBValidKey) => value.synced && key === 'sent', diff --git a/src/app/core/locale/services/locale-preference/locale-preference.service.ts b/src/app/core/locale/services/locale-preference/locale-preference.service.ts index 3b575996f..12f8a979e 100644 --- a/src/app/core/locale/services/locale-preference/locale-preference.service.ts +++ b/src/app/core/locale/services/locale-preference/locale-preference.service.ts @@ -18,12 +18,12 @@ import { isSupportedLocale, stripLocaleFromPathname } from '../../utils'; * preference. Because localization is build-time (`@angular/localize` serves one * compiled bundle per locale under `/en`, `/fr`, `/es`), switching language is a * hard navigation to the target locale sub-path, not a runtime string swap. - * * The explicit choice is persisted in the {@link LANG_COOKIE_NAME} cookie so the * SSR server serves the right bundle on the next request; clearing it falls back * to the browser `Accept-Language` header. * * @version 1.0.0 + * * @author Valentin FORTIN */ @Service() @@ -140,6 +140,7 @@ export class LocalePreferenceService { * @since 1.0.0 * * @param {AppLocaleSubPath} subPath - Locale sub-path to switch to. + * * @returns {void} */ public setLocale(subPath: AppLocaleSubPath): void { @@ -158,7 +159,7 @@ export class LocalePreferenceService { if (subPath === this.current()) return; const location = this.document.location; - this.document.location.assign( + this.navigate( `/${subPath}${stripLocaleFromPathname(location.pathname)}${location.search}${location.hash}`, ); } @@ -183,7 +184,7 @@ export class LocalePreferenceService { this.cookieService.deleteCookie(LANG_COOKIE_NAME); const location = this.document.location; - this.document.location.assign( + this.navigate( `${stripLocaleFromPathname(location.pathname)}${location.search}${location.hash}`, ); } @@ -203,6 +204,7 @@ export class LocalePreferenceService { * @since 1.1.0 * * @param {AppLocaleSubPath | 'system'} preference - Persisted display-language preference. + * * @returns {void} */ public applyPreference(preference: AppLocaleSubPath | 'system'): void { @@ -218,6 +220,29 @@ export class LocalePreferenceService { } } + /** + * Method navigate + * @method navigate + * + * @description + * Resolves a locale-switching destination with the browser URL parser and + * navigates only when the resolved URL retains the current origin. + * + * @access private + * @since unreleased + * + * @param {string} path - Destination path including its query and fragment. + * + * @returns {void} + */ + private navigate(path: string): void { + const location = this.document.location; + const destination = new URL(path, location.href); + if (destination.origin !== location.origin) return; + + location.assign(destination.href); + } + /** * Method fromLocaleId * @method fromLocaleId @@ -230,6 +255,7 @@ export class LocalePreferenceService { * @since 1.0.0 * * @param {string} localeId - Framework locale identifier. + * * @returns {AppLocaleSubPath} The matching locale sub-path. */ private fromLocaleId(localeId: string): AppLocaleSubPath { diff --git a/src/app/core/locale/services/locale-preference/testing/locale-preference.service.spec.ts b/src/app/core/locale/services/locale-preference/testing/locale-preference.service.spec.ts index 66480678c..86262e1ab 100644 --- a/src/app/core/locale/services/locale-preference/testing/locale-preference.service.spec.ts +++ b/src/app/core/locale/services/locale-preference/testing/locale-preference.service.spec.ts @@ -6,19 +6,37 @@ import { LANG_COOKIE_NAME } from '../../../constants/app-locale.constants'; import { LocalePreferenceService } from '../locale-preference.service'; describe('LocalePreferenceService', () => { - const assign = vi.fn(); - const cookie = { getCookie: vi.fn(), setCookie: vi.fn(), deleteCookie: vi.fn() }; - - function setup(pathname: string, localeId = 'es'): LocalePreferenceService { + const assign: ReturnType = vi.fn(); + const cookie: { + getCookie: ReturnType; + setCookie: ReturnType; + deleteCookie: ReturnType; + } = { getCookie: vi.fn(), setCookie: vi.fn(), deleteCookie: vi.fn() }; + + function setup( + pathname: string, + localeId = 'es', + options: { search?: string; hash?: string; platformId?: string } = {}, + ): LocalePreferenceService { + const origin = 'https://app.fireguard.test'; + const search = options.search ?? ''; + const hash = options.hash ?? ''; const document = { - location: { pathname, search: '', hash: '', assign }, + location: { + pathname, + search, + hash, + origin, + href: `${origin}${pathname}${search}${hash}`, + assign, + }, } as unknown as Document; TestBed.configureTestingModule({ providers: [ LocalePreferenceService, { provide: DOCUMENT, useValue: document }, - { provide: PLATFORM_ID, useValue: 'browser' }, + { provide: PLATFORM_ID, useValue: options.platformId ?? 'browser' }, { provide: LOCALE_ID, useValue: localeId }, { provide: CookieService, useValue: cookie }, ], @@ -42,7 +60,7 @@ describe('LocalePreferenceService', () => { expect(cookie.setCookie).toHaveBeenCalledWith( expect.objectContaining({ name: LANG_COOKIE_NAME, value: 'fr', path: '/' }), ); - expect(assign).toHaveBeenCalledWith('/fr/account'); + expect(assign).toHaveBeenCalledWith('https://app.fireguard.test/fr/account'); }); it('persists an explicit preference without navigating when its locale is already active', () => { @@ -60,7 +78,7 @@ describe('LocalePreferenceService', () => { expect(cookie.setCookie).toHaveBeenCalledWith( expect.objectContaining({ name: LANG_COOKIE_NAME, value: 'fr', path: '/' }), ); - expect(assign).toHaveBeenCalledWith('/fr/account'); + expect(assign).toHaveBeenCalledWith('https://app.fireguard.test/fr/account'); }); it('leaves a browser-default preference alone when no explicit cookie remains', () => { @@ -78,13 +96,62 @@ describe('LocalePreferenceService', () => { setup('/fr/account').applyPreference('system'); expect(cookie.deleteCookie).toHaveBeenCalledWith(LANG_COOKIE_NAME); - expect(assign).toHaveBeenCalledWith('/account'); + expect(assign).toHaveBeenCalledWith('https://app.fireguard.test/account'); }); it('clears the cookie and navigates to the locale-less path on browser default', () => { setup('/es/account').useBrowserDefault(); expect(cookie.deleteCookie).toHaveBeenCalledWith(LANG_COOKIE_NAME); - expect(assign).toHaveBeenCalledWith('/account'); + expect(assign).toHaveBeenCalledWith('https://app.fireguard.test/account'); + }); + + it.each(['/fr//attacker.example', '/fr/\\attacker.example'])( + 'keeps a system preference navigation on the current origin for %s', + (pathname) => { + cookie.getCookie.mockReturnValue('fr'); + + setup(pathname).applyPreference('system'); + + expect(cookie.deleteCookie).toHaveBeenCalledWith(LANG_COOKIE_NAME); + expect(assign).toHaveBeenCalledWith('https://app.fireguard.test/attacker.example'); + }, + ); + + it('preserves the route, query and fragment when switching explicit locales', () => { + setup('/en/account//files', 'en', { + search: '?return=%2F%2Fexample.test', + hash: '#details', + }).applyPreference('fr'); + + expect(assign).toHaveBeenCalledWith( + 'https://app.fireguard.test/fr/account//files?return=%2F%2Fexample.test#details', + ); + }); + + it('preserves encoded double slashes, the query and the fragment on browser default', () => { + setup('/fr/%2F%2Fattacker.example', 'fr', { + search: '?tab=profile', + hash: '#details', + }).useBrowserDefault(); + + expect(assign).toHaveBeenCalledWith( + 'https://app.fireguard.test/%2F%2Fattacker.example?tab=profile#details', + ); + }); + + it('blocks an origin-changing URL after URL parser normalization', () => { + setup('/fr/\t/attacker.example').useBrowserDefault(); + + expect(cookie.deleteCookie).toHaveBeenCalledWith(LANG_COOKIE_NAME); + expect(assign).not.toHaveBeenCalled(); + }); + + it('keeps preference reconciliation browser-only', () => { + setup('/fr/account', 'fr', { platformId: 'server' }).applyPreference('system'); + + expect(cookie.getCookie).not.toHaveBeenCalled(); + expect(cookie.deleteCookie).not.toHaveBeenCalled(); + expect(assign).not.toHaveBeenCalled(); }); }); diff --git a/src/app/core/locale/utils/locale-resolution/locale-resolution.utils.ts b/src/app/core/locale/utils/locale-resolution/locale-resolution.utils.ts index 7197b8801..a39655146 100644 --- a/src/app/core/locale/utils/locale-resolution/locale-resolution.utils.ts +++ b/src/app/core/locale/utils/locale-resolution/locale-resolution.utils.ts @@ -12,6 +12,7 @@ import type { AppLocaleSubPath } from '../../models/app-locale.type'; * Type guard narrowing an arbitrary string to a supported locale sub-path. * * @param {string | null | undefined} value - Candidate sub-path. + * * @returns {boolean} `true` when the value is a supported locale sub-path. */ export function isSupportedLocale(value: string | null | undefined): value is AppLocaleSubPath { @@ -26,6 +27,7 @@ export function isSupportedLocale(value: string | null | undefined): value is Ap * run both in the Express SSR server and in the browser. * * @param {string | null | undefined} header - Raw cookie header. + * * @returns {Record} Decoded cookie name/value pairs. */ export function parseCookieHeader(header: string | null | undefined): Record { @@ -53,6 +55,7 @@ export function parseCookieHeader(header: string | null | undefined): Record `/account`, `/es` -> `/`). + * a root-relative path (`/es/account` -> `/account`, `/es` -> `/`). Leading + * separators are collapsed so removing a locale cannot create a protocol-relative URL. * * @param {string} pathname - URL pathname, possibly locale-prefixed. - * @returns {string} The pathname without its locale prefix. + * + * @returns {string} The pathname without its locale prefix and with one leading slash. */ export function stripLocaleFromPathname(pathname: string): string { const segment = pathname.split('/')[1]; - if (!isSupportedLocale(segment)) return pathname; - - const rest = pathname.slice(segment.length + 1); - return rest.length > 0 ? rest : '/'; + const rest = isSupportedLocale(segment) ? pathname.slice(segment.length + 1) : pathname; + return `/${rest.replace(/^[/\\]+/, '')}`; } diff --git a/src/app/core/locale/utils/locale-resolution/testing/locale-resolution.utils.spec.ts b/src/app/core/locale/utils/locale-resolution/testing/locale-resolution.utils.spec.ts index c456c7895..41c8ce4aa 100644 --- a/src/app/core/locale/utils/locale-resolution/testing/locale-resolution.utils.spec.ts +++ b/src/app/core/locale/utils/locale-resolution/testing/locale-resolution.utils.spec.ts @@ -6,63 +6,76 @@ import { stripLocaleFromPathname, } from '../locale-resolution.utils'; -describe('locale-resolution.utils', () => { - describe('isSupportedLocale', () => { - it('accepts supported sub-paths and rejects others', () => { - expect(isSupportedLocale('fr')).toBe(true); - expect(isSupportedLocale('es')).toBe(true); - expect(isSupportedLocale('de')).toBe(false); - expect(isSupportedLocale(null)).toBe(false); - expect(isSupportedLocale(undefined)).toBe(false); - }); +describe('isSupportedLocale', () => { + it('accepts supported sub-paths and rejects others', () => { + expect(isSupportedLocale('fr')).toBe(true); + expect(isSupportedLocale('es')).toBe(true); + expect(isSupportedLocale('de')).toBe(false); + expect(isSupportedLocale(null)).toBe(false); + expect(isSupportedLocale(undefined)).toBe(false); }); +}); - describe('parseCookieHeader', () => { - it('parses and decodes cookie pairs', () => { - expect(parseCookieHeader('lang=es; theme=dark')).toEqual({ lang: 'es', theme: 'dark' }); - }); +describe('parseCookieHeader', () => { + it('parses and decodes cookie pairs', () => { + expect(parseCookieHeader('lang=es; theme=dark')).toEqual({ lang: 'es', theme: 'dark' }); + }); - it('returns an empty map for missing headers', () => { - expect(parseCookieHeader(undefined)).toEqual({}); - }); + it('returns an empty map for missing headers', () => { + expect(parseCookieHeader(undefined)).toEqual({}); }); +}); - describe('matchAcceptLanguage', () => { - it('matches the highest quality supported language', () => { - expect(matchAcceptLanguage('de-DE,fr;q=0.9,en;q=0.8')).toBe('fr'); - }); +describe('matchAcceptLanguage', () => { + it('matches the highest quality supported language', () => { + expect(matchAcceptLanguage('de-DE,fr;q=0.9,en;q=0.8')).toBe('fr'); + }); - it('matches on the primary sub-tag', () => { - expect(matchAcceptLanguage('es-419,es;q=0.9')).toBe('es'); - }); + it('matches on the primary sub-tag', () => { + expect(matchAcceptLanguage('es-419,es;q=0.9')).toBe('es'); + }); - it('returns null when nothing is supported', () => { - expect(matchAcceptLanguage('de,it')).toBeNull(); - expect(matchAcceptLanguage(undefined)).toBeNull(); - }); + it('returns null when nothing is supported', () => { + expect(matchAcceptLanguage('de,it')).toBeNull(); + expect(matchAcceptLanguage(undefined)).toBeNull(); }); +}); - describe('resolveLocaleFromRequest', () => { - it('prefers the explicit cookie choice', () => { - expect(resolveLocaleFromRequest('lang=fr', 'es')).toBe('fr'); - }); +describe('resolveLocaleFromRequest', () => { + it('prefers the explicit cookie choice', () => { + expect(resolveLocaleFromRequest('lang=fr', 'es')).toBe('fr'); + }); - it('falls back to Accept-Language when no valid cookie', () => { - expect(resolveLocaleFromRequest('lang=de', 'es-ES,es;q=0.9')).toBe('es'); - expect(resolveLocaleFromRequest(undefined, 'fr')).toBe('fr'); - }); + it('falls back to Accept-Language when no valid cookie', () => { + expect(resolveLocaleFromRequest('lang=de', 'es-ES,es;q=0.9')).toBe('es'); + expect(resolveLocaleFromRequest(undefined, 'fr')).toBe('fr'); + }); + + it('falls back to the default source locale otherwise', () => { + expect(resolveLocaleFromRequest(undefined, undefined)).toBe('en'); + expect(resolveLocaleFromRequest('lang=de', 'it')).toBe('en'); + }); +}); + +describe('stripLocaleFromPathname', () => { + it('removes a leading locale and keeps an absolute path', () => { + expect(stripLocaleFromPathname('/es/account')).toBe('/account'); + expect(stripLocaleFromPathname('/es')).toBe('/'); + expect(stripLocaleFromPathname('/account')).toBe('/account'); + }); - it('falls back to the default source locale otherwise', () => { - expect(resolveLocaleFromRequest(undefined, undefined)).toBe('en'); - expect(resolveLocaleFromRequest('lang=de', 'it')).toBe('en'); - }); + it.each([ + '/fr//attacker.example', + '/fr///attacker.example', + '/fr/\\attacker.example', + '/fr/\\/attacker.example', + '//attacker.example', + ])('keeps %s as a root-relative path', (pathname) => { + expect(stripLocaleFromPathname(pathname)).toBe('/attacker.example'); }); - describe('stripLocaleFromPathname', () => { - it('removes a leading locale and keeps an absolute path', () => { - expect(stripLocaleFromPathname('/es/account')).toBe('/account'); - expect(stripLocaleFromPathname('/es')).toBe('/'); - expect(stripLocaleFromPathname('/account')).toBe('/account'); - }); + it('preserves encoded separators and inner double slashes', () => { + expect(stripLocaleFromPathname('/fr/%2F%2Fattacker.example')).toBe('/%2F%2Fattacker.example'); + expect(stripLocaleFromPathname('/fr/account//files')).toBe('/account//files'); }); }); diff --git a/src/app/features/auth/FEATURE.md b/src/app/features/auth/FEATURE.md index 935a02ffd..dcbbe5bc4 100644 --- a/src/app/features/auth/FEATURE.md +++ b/src/app/features/auth/FEATURE.md @@ -153,6 +153,11 @@ It exposes the access token, initialization state, local session establishment, account signing in again; ordinary bearer renewal preserves it. Protected requests capture that revision before dispatch. A superseded request cannot renew, replay, clear, or navigate another session. +Inventory consumes the public `ports` barrel for `sessionRevision` and established-session checks +when persisting or replaying a physical return or correction. It never reads or stores bearer tokens. +User and organization identifiers remain supplied by the authorized member context; a renewed +session for the same account cannot acknowledge an earlier session's pending continuation. + `isAuthenticated` denotes an established, non-invalidated local session outside MFA. Bearer expiry alone does not end it: a 401 can renew it silently. `isTokenExpiringSoon()` evaluates time when called; it is not a reactive clock. Bootstrap, explicit refresh and interceptor renewal share one request per diff --git a/src/app/features/onboarding/FEATURE.md b/src/app/features/onboarding/FEATURE.md index 944d60848..6d9655ea4 100644 --- a/src/app/features/onboarding/FEATURE.md +++ b/src/app/features/onboarding/FEATURE.md @@ -24,9 +24,11 @@ its published setup boundary. workspace access to the entry resolver. A failed onboarding read is an unknown state, not proof that activation is incomplete; organization access guards remain authoritative. The dashboard parent authenticates before this guard runs on its Account/Organization children, preserving anonymous deep-link destinations. -- Completing activation opens `/organizations/:targetOrganizationId`; when no target exists, - a safe `returnUrl` or `/` is the fallback. Visiting an already completed wizard instead - resumes its safe `returnUrl`, falling back to the target organization and then `/`. +- Completing activation retains a safe deep link inside the completed target organization; + otherwise its default is `/organizations/:targetOrganizationId/assets` when API-confirmed + facilities-read is granted, or the historical organization dashboard. A link to another + organization does not replace the newly created target. With no target, a safe return URL or + `/` remains the fallback. Visiting an already completed wizard resumes its safe return URL first. - The shell mounts auth's `withLogoutControl()` because the mandatory gate must still permit signing out. The dashboard parent rechecks authentication on navigation; maintenance retains its existing scope. @@ -54,15 +56,16 @@ explicit local retry. Subscriptions end with the page. ## Cross-Feature Dependencies -- `@features/organization/setup`: activation commands, role/site summaries and the +- `@features/organization/setup`: activation commands, role/site summaries, active equipment-type choices and the `organizationInvitationAcceptStoreEvents.acceptSucceeded` event that invalidates onboarding before guards reload. - `@features/organization/data-access`: root `PlanService` and `BillingService`, because onboarding runs outside the dashboard's scoped organization provider. - `@features/organization/models`: plans, billing, access policies, domains and admission contracts. - `@features/organization/utils`: localized admission failure mapping. +- `@features/organization/services/organization-landing`: the narrowly published default + destination facade, using the target organization's existing access coordinator and grants. - `@features/auth/data-access`, `models` and `ui/forms`: mailbox proof transport and OTP form. - `@features/account`: notification-center invalidation counter; no private notification content. -- `@features/organization/features/equipments`: the canonical `EQUIPMENT_TYPE_OPTIONS`. - `@features/auth`: session lifecycle events; `@features/auth/utils`: safe return URL validation. - `@shared/layout-slot`: the layout contribution contract. @@ -70,6 +73,11 @@ The organization surfaces listed here are the complete lint-enforced allowlist. subfeature store or private transport is consumed by onboarding. Facility type choices remain local until their owning feature publishes an approved shared contract. +Explicit organization dashboard bookmarks remain valid. Workspace selection preserves safe +client/site query context in the selected organization. Superseded permission reads cannot +navigate back to an earlier selection. Exploitant and Prestataire profiles do not grant access; +the same facilities-read check determines their default fleet entry. + ## UI - Central mobile mode selects searchable drawers for equipment type and multi-site choices; @@ -109,6 +117,11 @@ local until their owning feature publishes an approved shared contract. - Equipment restores persisted sites when the wizard resumes. One site is automatically attached and shown as a summary; multiple sites require an explicit searchable selection. The site ID is mapped by the setup facade to the facility IRI in the same equipment creation request. +- Equipment choices come from the organization's complete active server catalogue through setup, + loaded after browser rendering only while the equipment step is active. Obsolete organization/step + reads are cancelled. Empty and failed reads expose explicit refresh without retry loops. + Fire types are shown initially; all active families remain selectable. Custom codes remain strings; + prepared codes and other fields are retained, but an unavailable type cannot create new equipment. - API command failures use the app-wide feedback toast once per failed request; forms retain only local field validation. The page keeps retry controls and failed-row markers without repeating the API message inline. Catalog loading, empty responses, recoverable failures and blocked activation are distinct. diff --git a/src/app/features/onboarding/http/guards/onboarding.guard.ts b/src/app/features/onboarding/http/guards/onboarding.guard.ts index d5437a62b..f116558ac 100644 --- a/src/app/features/onboarding/http/guards/onboarding.guard.ts +++ b/src/app/features/onboarding/http/guards/onboarding.guard.ts @@ -1,18 +1,19 @@ import { inject } from '@angular/core'; import { type CanActivateFn, GuardResult, MaybeAsync, Router } from '@angular/router'; -import { map } from 'rxjs'; +import { map, of, switchMap } from 'rxjs'; import { resolveReturnUrl } from '@features/auth/utils'; import type { OnboardingOutput } from '@features/onboarding/models'; import { OnboardingStore } from '@features/onboarding/state'; +import { OrganizationLandingService } from '@features/organization/services/organization-landing'; /** - * Guard onboardingGuard + * Function onboardingGuard * * @description * Wizard-access guard for the `/onboarding` route. The wizard itself never blocks on its own record: * it never prevents access to the application. This guard only protects the - * dedicated activation wizard, redirecting to the dashboard (`/`) when the flow - * is already `completed` so users do not land on an empty wizard. In-progress and + * dedicated activation wizard, restoring a safe return URL or its permission-gated target + * workspace when the flow is already `completed`. In-progress and * dismissed flows are allowed through (the dismissed checklist is independent of * the wizard surface). * @@ -20,6 +21,7 @@ import { OnboardingStore } from '@features/onboarding/state'; * cached record when present or fetches it once, failing safe to `null`. * * @version 2.0.0 + * * @author Valentin FORTIN * * @return {MaybeAsync} `true` when the wizard may open, or a @@ -28,16 +30,19 @@ import { OnboardingStore } from '@features/onboarding/state'; export const onboardingGuard: CanActivateFn = (route): MaybeAsync => { const onboardingStore: OnboardingStore = inject(OnboardingStore); const router: Router = inject(Router); + const landing: OrganizationLandingService = inject(OrganizationLandingService); return onboardingStore.ensureLoaded().pipe( - map((onboarding: OnboardingOutput | null): GuardResult => { - if (onboarding?.state !== 'completed') return true; + switchMap((onboarding: OnboardingOutput | null) => { + if (onboarding?.state !== 'completed') return of(true); const returnUrl = resolveReturnUrl(route.queryParamMap.get('returnUrl'), ''); if (returnUrl && !returnUrl.split('?')[0].startsWith('/onboarding')) - return router.parseUrl(returnUrl); + return of(router.parseUrl(returnUrl)); return onboarding.targetOrganizationId - ? router.createUrlTree(['/organizations', onboarding.targetOrganizationId]) - : router.createUrlTree(['/']); + ? landing + .defaultDestination(onboarding.targetOrganizationId) + .pipe(map((destination): GuardResult => router.parseUrl(destination))) + : of(router.createUrlTree(['/'])); }), ); }; diff --git a/src/app/features/onboarding/http/guards/testing/onboarding.guard.spec.ts b/src/app/features/onboarding/http/guards/testing/onboarding.guard.spec.ts index bc7d819c2..eda42bd13 100644 --- a/src/app/features/onboarding/http/guards/testing/onboarding.guard.spec.ts +++ b/src/app/features/onboarding/http/guards/testing/onboarding.guard.spec.ts @@ -3,13 +3,15 @@ import { convertToParamMap, Router, UrlTree } from '@angular/router'; import { type Observable, firstValueFrom, isObservable, of } from 'rxjs'; import type { OnboardingOutput } from '@features/onboarding/models'; import { OnboardingStore } from '@features/onboarding/state'; +import { OrganizationLandingService } from '@features/organization/services/organization-landing'; import { onboardingGuard } from '../onboarding.guard'; const onboardingWith = (s: OnboardingOutput['state']): OnboardingOutput => ({ state: s }) as OnboardingOutput; describe('onboardingGuard', () => { - let mockRouter: { createUrlTree: ReturnType }; + let mockRouter: { createUrlTree: ReturnType; parseUrl: ReturnType }; + let defaultDestination: ReturnType; let mockStore: { ensureLoaded: ReturnType }; const dashboardUrlTree = {} as UrlTree; const route = { queryParamMap: convertToParamMap({}) } as unknown as Parameters< @@ -17,21 +19,30 @@ describe('onboardingGuard', () => { >[0]; const state = {} as unknown as Parameters[1]; - async function runGuard(): Promise { - const result = TestBed.runInInjectionContext(() => onboardingGuard(route, state)); + async function runGuard( + selectedRoute: Parameters[0] = route, + ): Promise { + const result = TestBed.runInInjectionContext(() => onboardingGuard(selectedRoute, state)); return isObservable(result) ? firstValueFrom(result as Observable) : (result as boolean | UrlTree); } beforeEach(() => { - mockRouter = { createUrlTree: vi.fn().mockReturnValue(dashboardUrlTree) }; + mockRouter = { + createUrlTree: vi.fn().mockReturnValue(dashboardUrlTree), + parseUrl: vi.fn().mockReturnValue(dashboardUrlTree), + }; + defaultDestination = vi.fn((organizationId: string) => + of(`/organizations/${organizationId}/assets`), + ); mockStore = { ensureLoaded: vi.fn() }; TestBed.configureTestingModule({ providers: [ { provide: Router, useValue: mockRouter }, { provide: OnboardingStore, useValue: mockStore }, + { provide: OrganizationLandingService, useValue: { defaultDestination } }, ], }); }); @@ -56,6 +67,18 @@ describe('onboardingGuard', () => { of({ ...onboardingWith('completed'), targetOrganizationId: 'org-created' }), ); await expect(runGuard()).resolves.toBe(dashboardUrlTree); - expect(mockRouter.createUrlTree).toHaveBeenCalledWith(['/organizations', 'org-created']); + expect(defaultDestination).toHaveBeenCalledWith('org-created'); + expect(mockRouter.parseUrl).toHaveBeenCalledWith('/organizations/org-created/assets'); + }); + it('keeps an explicit safe historical deep link ahead of the default fleet destination', async () => { + mockStore.ensureLoaded.mockReturnValue( + of({ ...onboardingWith('completed'), targetOrganizationId: 'org-created' }), + ); + const selectedRoute = { + queryParamMap: convertToParamMap({ returnUrl: '/organizations/org-created?tab=overview' }), + } as unknown as Parameters[0]; + await runGuard(selectedRoute); + expect(mockRouter.parseUrl).toHaveBeenCalledWith('/organizations/org-created?tab=overview'); + expect(defaultDestination).not.toHaveBeenCalled(); }); }); diff --git a/src/app/features/onboarding/ui/forms/onboarding-equipment-form/models/onboarding-equipment-form-draft.model.ts b/src/app/features/onboarding/ui/forms/onboarding-equipment-form/models/onboarding-equipment-form-draft.model.ts index bc26ed425..d7a4f6086 100644 --- a/src/app/features/onboarding/ui/forms/onboarding-equipment-form/models/onboarding-equipment-form-draft.model.ts +++ b/src/app/features/onboarding/ui/forms/onboarding-equipment-form/models/onboarding-equipment-form-draft.model.ts @@ -1,17 +1,15 @@ -import type { EQUIPMENT_TYPE_OPTIONS } from '@features/organization/features/equipments'; - /** * Type OnboardingEquipmentTypeOption * * @description - * The equipment type values the onboarding step may pick, inferred from - * {@link EQUIPMENT_TYPE_OPTIONS} rather than the equipments subfeature's own - * `EquipmentType` model type — that type lives on a private path onboarding - * may not import (`FEATURE.md` "Cross-Feature Dependencies"). + * Permanent equipment code received through the organization setup catalogue, + * including organization-defined types and previously prepared historical codes. * * @since 1.0.0 + * + * @type {OnboardingEquipmentTypeOption} */ -export type OnboardingEquipmentTypeOption = (typeof EQUIPMENT_TYPE_OPTIONS)[number]['value']; +export type OnboardingEquipmentTypeOption = string; /** * Interface OnboardingEquipmentFormDraft @@ -25,18 +23,58 @@ export type OnboardingEquipmentTypeOption = (typeof EQUIPMENT_TYPE_OPTIONS)[numb * @since 1.0.0 */ export interface OnboardingEquipmentFormDraft { - /** The equipment type, or an empty string until one is picked. */ + /** + * Property type + * @readonly + * + * @description + * Server-owned equipment code, or an empty string until one is picked. + * + * @type {OnboardingEquipmentTypeOption | ''} + */ readonly type: OnboardingEquipmentTypeOption | ''; - /** Manufacturer brand. */ + /** + * Property brand + * @readonly + * + * @description + * Manufacturer brand retained in the editable draft. + * + * @type {string} + */ readonly brand: string; - /** Model reference. */ + /** + * Property model + * @readonly + * + * @description + * Manufacturer model reference retained in the editable draft. + * + * @type {string} + */ readonly model: string; - /** Manufacturer serial number. */ + /** + * Property serialNumber + * @readonly + * + * @description + * Manufacturer serial number retained in the editable draft. + * + * @type {string} + */ readonly serialNumber: string; - /** The target facility's id, or an empty string when none is attached. */ + /** + * Property facilityId + * @readonly + * + * @description + * Target site's identity, or an empty string when none is attached. + * + * @type {string} + */ readonly facilityId: string; } diff --git a/src/app/features/onboarding/ui/forms/onboarding-equipment-form/onboarding-equipment-form.component.html b/src/app/features/onboarding/ui/forms/onboarding-equipment-form/onboarding-equipment-form.component.html index 36d454e0b..9ea3aaadc 100644 --- a/src/app/features/onboarding/ui/forms/onboarding-equipment-form/onboarding-equipment-form.component.html +++ b/src/app/features/onboarding/ui/forms/onboarding-equipment-form/onboarding-equipment-form.component.html @@ -5,6 +5,38 @@ >
+ + + + + @if (visibleTypeOptions().length === 0 && typeScope() === 'fire') { +

+ No active fire equipment types. Choose All equipment to view the other available types. +

+ } Type No matching type.
- @for (option of typeOptions; track option.value) { + @for (option of visibleTypeOptions(); track option.value) { + + + Choose how you manage your fire-safety equipment. Access remains determined by your + organization's permissions. +
diff --git a/src/app/features/onboarding/ui/forms/onboarding-organization-form/onboarding-organization-form.component.ts b/src/app/features/onboarding/ui/forms/onboarding-organization-form/onboarding-organization-form.component.ts index 0deb7e495..ef86b50d9 100644 --- a/src/app/features/onboarding/ui/forms/onboarding-organization-form/onboarding-organization-form.component.ts +++ b/src/app/features/onboarding/ui/forms/onboarding-organization-form/onboarding-organization-form.component.ts @@ -14,10 +14,20 @@ import type { SetupCreateOrganizationInput } from '@features/organization/setup' import { RequiredMarker } from '@shared/required-marker'; import { HlmFieldImports } from '@shared/ui/field'; import { HlmInput } from '@shared/ui/input'; +import { HlmToggleGroupImports } from '@shared/ui/toggle-group'; import type { OnboardingOrganizationFormDraft } from './models'; -/** A blank draft. */ -const EMPTY_VALUES: OnboardingOrganizationFormDraft = { name: '' }; +/** + * Constant EMPTY_VALUES + * + * @description + * A blank draft. + * + * @type {OnboardingOrganizationFormDraft} + * + * @const EMPTY_VALUES + */ +const EMPTY_VALUES: OnboardingOrganizationFormDraft = { name: '', operatingProfile: 'operator' }; /** * Component OnboardingOrganizationForm @@ -35,16 +45,23 @@ const EMPTY_VALUES: OnboardingOrganizationFormDraft = { name: '' }; * * @version 1.0.0 * + * @author Valentin FORTIN + * * @example * ```html * * ``` - * - * @author Valentin FORTIN */ @Component({ selector: 'app-onboarding-organization-form', - imports: [RequiredMarker, FormField, HlmInput, OnboardingStepFooter, ...HlmFieldImports], + imports: [ + RequiredMarker, + FormField, + HlmInput, + OnboardingStepFooter, + ...HlmFieldImports, + ...HlmToggleGroupImports, + ], templateUrl: './onboarding-organization-form.component.html', host: { class: 'block' }, changeDetection: ChangeDetectionStrategy.OnPush, @@ -53,9 +70,13 @@ export class OnboardingOrganizationForm { /** * Property restored * @readonly - * @description Pending fields restored from the server before any durable creation result exists. + * + * @description + * Pending fields restored from the server before any durable creation result exists. + * * @access public * @since 1.1.0 + * * @type {InputSignal} */ public readonly restored: InputSignal = @@ -65,9 +86,13 @@ export class OnboardingOrganizationForm { /** * Property pending * @readonly - * @description Whether the organization is being created, which locks the controls. + * + * @description + * Whether the organization is being created, which locks the controls. + * * @access public * @since 1.0.0 + * * @type {InputSignal} */ public readonly pending: InputSignal = input(false); @@ -75,9 +100,14 @@ export class OnboardingOrganizationForm { /** * Property skippable * @readonly - * @description Whether the backend currently lets this step be skipped. Always false here — the organization is the one required step — but every step form shares the footer contract. + * + * @description + * Whether the backend currently lets this step be skipped. Always false here — the organization + * is the one required step — but every step form shares the footer contract. + * * @access public * @since 1.1.0 + * * @type {InputSignal} */ public readonly skippable: InputSignal = input(false); @@ -87,9 +117,13 @@ export class OnboardingOrganizationForm { /** * Property submitted * @readonly - * @description Emits the setup-boundary payload once the form is valid. + * + * @description + * Emits the setup-boundary payload once the form is valid. + * * @access public * @since 1.0.0 + * * @type {OutputEmitterRef} */ public readonly submitted: OutputEmitterRef = @@ -98,32 +132,80 @@ export class OnboardingOrganizationForm { /** * Property skipped * @readonly - * @description Relays the footer's skip request to the page. + * + * @description + * Relays the footer's skip request to the page. + * * @access public * @since 1.1.0 + * * @type {OutputEmitterRef} */ public readonly skipped: OutputEmitterRef = output(); //#endregion //#region Properties - /** The footer's resting label — the step's verb, not a generic "Continue". */ + /** + * Property submitLabel + * @readonly + * + * @description + * The footer's resting label — the step's verb, not a generic "Continue". + * + * @access protected + * @since 1.0.0 + * + * @type {string} + * + * @property submitLabel + */ protected readonly submitLabel: string = $localize`:@@onboarding.orgForm.submit:Create organization`; - /** The footer's label while the organization is being created. */ + /** + * Property pendingLabel + * @readonly + * + * @description + * The footer's label while the organization is being created. + * + * @access protected + * @since 1.0.0 + * + * @type {string} + * + * @property pendingLabel + */ protected readonly pendingLabel: string = $localize`:@@onboarding.orgForm.submitting:Creating…`; - /** The edited draft. */ + /** + * Property model + * @readonly + * + * @description + * The edited draft. + * + * @access protected + * @since 1.1.0 + * + * @type {WritableSignal} + * + * @property model + */ protected readonly model: WritableSignal = linkedSignal(() => ({ name: this.restored()?.name ?? EMPTY_VALUES.name, + operatingProfile: this.restored()?.operatingProfile ?? EMPTY_VALUES.operatingProfile, })); /** * Property organizationForm * @readonly - * @description The field tree and its one rule. + * + * @description + * The field tree and its one rule. + * * @access protected * @since 1.0.0 + * * @type {FieldTree} */ protected readonly organizationForm: FieldTree = form( @@ -161,7 +243,27 @@ export class OnboardingOrganizationForm { const draft: OnboardingOrganizationFormDraft = this.model(); - this.submitted.emit({ name: draft.name.trim() }); + this.submitted.emit({ name: draft.name.trim(), operatingProfile: draft.operatingProfile }); + } + + /** + * Method changeOperatingProfile + * + * @description + * Writes the selected profile into the same durable Signal Forms draft. + * + * @access protected + * @since 1.1.0 + * + * @param {string | readonly string[] | null | undefined} value - Native toggle-group selection. + * + * @returns {void} + */ + protected changeOperatingProfile(value: string | readonly string[] | null | undefined): void { + if (value === 'operator' || value === 'service_provider') { + this.organizationForm.operatingProfile().value.set(value); + this.organizationForm.operatingProfile().markAsTouched(); + } } //#endregion } diff --git a/src/app/features/onboarding/ui/forms/onboarding-organization-form/testing/onboarding-organization-form.component.spec.ts b/src/app/features/onboarding/ui/forms/onboarding-organization-form/testing/onboarding-organization-form.component.spec.ts index e604bd62f..79221f861 100644 --- a/src/app/features/onboarding/ui/forms/onboarding-organization-form/testing/onboarding-organization-form.component.spec.ts +++ b/src/app/features/onboarding/ui/forms/onboarding-organization-form/testing/onboarding-organization-form.component.spec.ts @@ -51,7 +51,35 @@ describe('OnboardingOrganizationForm', () => { await fill('onboarding-org-name', ' Acme Fire Safety '); await submit(); - expect(emitted).toEqual([{ name: 'Acme Fire Safety' }]); + expect(emitted).toEqual([{ name: 'Acme Fire Safety', operatingProfile: 'operator' }]); + }); + + it('retains the selected service-provider profile in the creation payload', async () => { + const emitted: SetupCreateOrganizationInput[] = []; + fixture.componentInstance.submitted.subscribe((value: SetupCreateOrganizationInput): void => { + emitted.push(value); + }); + await fill('onboarding-org-name', 'FireGuard Services'); + element + .querySelector('[data-testid="onboarding-org-profile-provider"]') + ?.click(); + await fixture.whenStable(); + await submit(); + expect(emitted).toEqual([{ name: 'FireGuard Services', operatingProfile: 'service_provider' }]); + }); + + it('restores the profile together with the pending organization draft', async () => { + fixture.componentRef.setInput('restored', { + name: 'FireGuard Services', + operatingProfile: 'service_provider', + }); + await fixture.whenStable(); + const emitted: SetupCreateOrganizationInput[] = []; + fixture.componentInstance.submitted.subscribe((value: SetupCreateOrganizationInput): void => { + emitted.push(value); + }); + await submit(); + expect(emitted).toEqual([{ name: 'FireGuard Services', operatingProfile: 'service_provider' }]); }); it('should lock the submit control while a request is in flight', async () => { diff --git a/src/app/features/onboarding/ui/pages/onboarding-wizard-page/onboarding-wizard-page.component.html b/src/app/features/onboarding/ui/pages/onboarding-wizard-page/onboarding-wizard-page.component.html index 383fccd1c..2d21ba422 100644 --- a/src/app/features/onboarding/ui/pages/onboarding-wizard-page/onboarding-wizard-page.component.html +++ b/src/app/features/onboarding/ui/pages/onboarding-wizard-page/onboarding-wizard-page.component.html @@ -185,6 +185,44 @@ + } @else if (step.key === 'create_first_equipment' && equipmentTypeOptions().length === 0) { +
+
+

+ No active equipment types +

+

+ An active type is needed to register equipment. Refresh the available choices after the + organization's types have been updated. +

+
+
+ + @if (canSkip()) { + + } +
+
} @else { @switch (step.key) { @case ('create_organization') { @@ -241,6 +279,7 @@ } + */ + private readonly completedDestinations: Subject<{ organizationId: string; requested: string }> = + new Subject(); + /** * Property addressSearch * @readonly @@ -309,6 +342,76 @@ export class OnboardingWizardPage implements OnInit { */ protected readonly facilitiesCallState: WritableSignal> = signal(idleCallState()); + /** + * Property equipmentTypesCallState + * @readonly + * + * @description + * Equipment catalogue loading is distinct from persisted site recovery. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal>} + */ + protected readonly equipmentTypesCallState: WritableSignal> = + signal(idleCallState()); + + /** + * Property equipmentTypeOptions + * @readonly + * + * @description + * Active choices published by setup include custom organization codes. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal} + */ + protected readonly equipmentTypeOptions: WritableSignal = + signal([]); + + /** + * Property equipmentTypesBrowserReady + * @readonly + * + * @description + * Equipment catalogue requests begin after browser rendering, never during SSR. + * + * @access private + * @since unreleased + * + * @type {WritableSignal} + */ + private readonly equipmentTypesBrowserReady: WritableSignal = signal(false); + + /** + * Property equipmentTypesOrganizationId + * + * @description + * Identifies the catalogue's owner independently of the current wizard target. + * + * @access private + * @since unreleased + * + * @type {string | null} + */ + private equipmentTypesOrganizationId: string | null = null; + + /** + * Property equipmentTypesSubscription + * + * @description + * Cancels obsolete private catalogue reads when the step or organization changes. + * + * @access private + * @since unreleased + * + * @type {import('rxjs').Subscription | null} + */ + private equipmentTypesSubscription: import('rxjs').Subscription | null = null; + /** * Property catalogError * @readonly @@ -328,7 +431,7 @@ export class OnboardingWizardPage implements OnInit { case 'invite_members': return this.rolesCallState().error; case 'create_first_equipment': - return this.facilitiesCallState().error; + return this.equipmentTypesCallState().error ?? this.facilitiesCallState().error; default: return null; } @@ -898,10 +1001,16 @@ export class OnboardingWizardPage implements OnInit { * * @type {WritableSignal} */ - protected readonly catalogPending: Signal = computed(() => - [this.planCatalogCallState(), this.rolesCallState(), this.facilitiesCallState()].some( - (state) => state.status === 'pending', - ), + protected readonly catalogPending: Signal = computed( + () => + [ + this.planCatalogCallState(), + this.rolesCallState(), + this.facilitiesCallState(), + this.equipmentTypesCallState(), + ].some((state) => state.status === 'pending') || + (this.currentStep()?.key === 'create_first_equipment' && + this.equipmentTypesCallState().status === 'idle'), ); /** @@ -952,7 +1061,41 @@ export class OnboardingWizardPage implements OnInit { * @access public * @since unreleased */ - constructor() { + public constructor() { + if (isPlatformBrowser(inject(PLATFORM_ID))) { + afterNextRender(() => this.equipmentTypesBrowserReady.set(true)); + } + effect(() => { + const organizationId = this.store.targetOrganizationId(); + const active = this.currentStep()?.key === 'create_first_equipment'; + const ready = this.equipmentTypesBrowserReady(); + untracked(() => { + if (!active || !organizationId || !ready) { + this.equipmentTypesSubscription?.unsubscribe(); + if (this.equipmentTypesCallState().status === 'pending') + this.equipmentTypesCallState.set(idleCallState()); + return; + } + if ( + organizationId !== this.equipmentTypesOrganizationId || + this.equipmentTypesCallState().status === 'idle' + ) + this.loadEquipmentTypes(); + }); + }); + this.completedDestinations + .pipe( + switchMap(({ organizationId, requested }) => { + const root: string = `/organizations/${encodeURIComponent(organizationId)}`; + return requested === root || + requested.startsWith(`${root}/`) || + requested.startsWith(`${root}?`) + ? of(requested) + : this.landing.defaultDestination(organizationId); + }), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe((destination) => void this.router.navigateByUrl(destination)); let wasPending = false; effect(() => { const pending = this.stepPending(); @@ -1035,10 +1178,14 @@ export class OnboardingWizardPage implements OnInit { $localize`:@@onboarding.wizard.completed:Your organization is ready.`, ); const organizationId = this.store.targetOrganizationId(); - const destination = organizationId - ? `/organizations/${organizationId}` - : resolveReturnUrl(this.route.snapshot.queryParamMap.get('returnUrl'), '/'); - void this.router.navigateByUrl(destination); + const requested: string = resolveReturnUrl( + this.route.snapshot.queryParamMap.get('returnUrl'), + '', + ); + untracked(() => { + if (organizationId) this.completedDestinations.next({ organizationId, requested }); + else void this.router.navigateByUrl(requested || '/'); + }); } }); @@ -1385,6 +1532,51 @@ export class OnboardingWizardPage implements OnInit { }); } + /** + * Method loadEquipmentTypes + * @method loadEquipmentTypes + * + * @description + * Loads active equipment choices with explicit retries and cancellation of obsolete reads. + * + * @access private + * @since unreleased + * + * @returns {void} + */ + private loadEquipmentTypes(): void { + const organizationId = this.store.targetOrganizationId(); + if ( + !organizationId || + !this.equipmentTypesBrowserReady() || + this.currentStep()?.key !== 'create_first_equipment' + ) + return; + this.equipmentTypesSubscription?.unsubscribe(); + if (organizationId !== this.equipmentTypesOrganizationId) this.equipmentTypeOptions.set([]); + this.equipmentTypesOrganizationId = organizationId; + this.equipmentTypesCallState.set(pendingCallState()); + this.equipmentTypesSubscription = this.organizationSetupService + .listEquipmentTypes(organizationId) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: (types) => { + this.equipmentTypeOptions.set(types); + this.equipmentTypesCallState.set(successCallState(undefined)); + }, + error: (error: unknown) => { + const failure = toStoreError(error); + this.equipmentTypesCallState.set(errorCallState(failure)); + this.feedback.show( + toStoreFailureEventPayload( + failure, + $localize`:@@onboarding.wizard.catalogFailed:The available choices could not be loaded.`, + ), + ); + }, + }); + } + /** * Method loadFacilities * @method loadFacilities @@ -1444,7 +1636,8 @@ export class OnboardingWizardPage implements OnInit { this.loadRoles(); break; case 'create_first_equipment': - this.loadFacilities(); + if (this.facilitiesCallState().status !== 'success') this.loadFacilities(); + if (this.equipmentTypesCallState().status !== 'pending') this.loadEquipmentTypes(); break; } } diff --git a/src/app/features/onboarding/ui/pages/onboarding-wizard-page/testing/onboarding-wizard-page.component.spec.ts b/src/app/features/onboarding/ui/pages/onboarding-wizard-page/testing/onboarding-wizard-page.component.spec.ts index eaaef40f2..707626765 100644 --- a/src/app/features/onboarding/ui/pages/onboarding-wizard-page/testing/onboarding-wizard-page.component.spec.ts +++ b/src/app/features/onboarding/ui/pages/onboarding-wizard-page/testing/onboarding-wizard-page.component.spec.ts @@ -1,4 +1,12 @@ -import { provideZonelessChangeDetection, signal, type WritableSignal } from '@angular/core'; +import { + createComponent, + createEnvironmentInjector, + EnvironmentInjector, + PLATFORM_ID, + provideZonelessChangeDetection, + signal, + type WritableSignal, +} from '@angular/core'; import { TestBed, type ComponentFixture } from '@angular/core/testing'; import { ActivatedRoute, convertToParamMap, Router } from '@angular/router'; import { Dispatcher } from '@ngrx/signals/events'; @@ -22,6 +30,7 @@ import { onboardingSetupEvents, } from '@features/onboarding/state'; import { BillingService, PlanService } from '@features/organization/data-access'; +import { OrganizationLandingService } from '@features/organization/services/organization-landing'; import { OrganizationSetupService, type SetupCreateFacilityInput, @@ -82,6 +91,7 @@ describe('OnboardingWizardPage', () => { createEquipment: ReturnType; listRoles: ReturnType; listFacilities: ReturnType; + listEquipmentTypes: ReturnType; }; let setupMock: { operations: WritableSignal; @@ -101,6 +111,18 @@ describe('OnboardingWizardPage', () => { let routerMock: { navigateByUrl: ReturnType }; let feedbackMock: { success: ReturnType; show: ReturnType }; + beforeAll(() => + vi.stubGlobal( + 'ResizeObserver', + class { + observe(): void {} + unobserve(): void {} + disconnect(): void {} + }, + ), + ); + afterAll(() => vi.unstubAllGlobals()); + beforeEach(async () => { storeMock = { startCallState: signal(idleCallState()), @@ -134,6 +156,16 @@ describe('OnboardingWizardPage', () => { createEquipment: vi.fn().mockReturnValue(of(undefined)), listRoles: vi.fn().mockReturnValue(of([])), listFacilities: vi.fn().mockReturnValue(of([])), + listEquipmentTypes: vi.fn().mockReturnValue( + of([ + { + value: 'fire_extinguisher', + label: 'Fire extinguisher', + family: 'fire', + icon: 'lucideFireExtinguisher', + }, + ]), + ), }; setupMock = { operations: signal([]), @@ -176,6 +208,14 @@ describe('OnboardingWizardPage', () => { { provide: OrganizationSetupService, useValue: organizationSetupServiceMock }, { provide: PlanService, useValue: planServiceMock }, { provide: BillingService, useValue: billingServiceMock }, + { + provide: OrganizationLandingService, + useValue: { + defaultDestination: vi.fn((organizationId: string) => + of(`/organizations/${organizationId}/assets`), + ), + }, + }, { provide: Router, useValue: routerMock }, { provide: ActivatedRoute, @@ -193,6 +233,97 @@ describe('OnboardingWizardPage', () => { await fixture.whenStable(); }); + it('loads equipment choices only when that step is visible and cancels an obsolete organization read', async () => { + expect(organizationSetupServiceMock.listEquipmentTypes).not.toHaveBeenCalled(); + const oldRead = new Subject< + readonly { value: string; label: string; family: 'fire'; icon: string }[] + >(); + const newRead = new Subject< + readonly { value: string; label: string; family: 'fire'; icon: string }[] + >(); + organizationSetupServiceMock.listEquipmentTypes + .mockReturnValueOnce(oldRead) + .mockReturnValueOnce(newRead); + storeMock.targetOrganizationId.set('org-1'); + storeMock.nextStep.set('create_first_equipment'); + storeMock.steps.set([stepOf('create_first_equipment', 'pending')]); + await fixture.whenStable(); + expect(organizationSetupServiceMock.listEquipmentTypes).toHaveBeenCalledWith('org-1'); + expect( + (fixture.nativeElement as HTMLElement).querySelector( + '[data-testid="onboarding-catalog-loading"]', + ), + ).not.toBeNull(); + storeMock.targetOrganizationId.set('org-2'); + await fixture.whenStable(); + oldRead.next([{ value: 'obsolete', label: 'Obsolete', family: 'fire', icon: 'lucideBox' }]); + expect(fixture.componentInstance['equipmentTypeOptions']()).toEqual([]); + newRead.next([{ value: 'custom', label: 'Custom', family: 'fire', icon: 'lucideBox' }]); + await fixture.whenStable(); + expect( + fixture.componentInstance['equipmentTypeOptions']().map((option) => option.value), + ).toEqual(['custom']); + }); + + it('shows a retryable catalogue failure without automatically refetching or duplicating successful site recovery', async () => { + organizationSetupServiceMock.listEquipmentTypes.mockReturnValueOnce( + throwError(() => new Error('Catalogue unavailable')), + ); + storeMock.targetOrganizationId.set('org-1'); + storeMock.nextStep.set('create_first_equipment'); + storeMock.steps.set([stepOf('create_first_equipment', 'pending')]); + await fixture.whenStable(); + expect(organizationSetupServiceMock.listEquipmentTypes).toHaveBeenCalledTimes(1); + expect( + (fixture.nativeElement as HTMLElement).querySelector( + '[data-testid="onboarding-catalog-error"]', + ), + ).not.toBeNull(); + fixture.componentInstance['retryCatalog'](); + await fixture.whenStable(); + expect(organizationSetupServiceMock.listEquipmentTypes).toHaveBeenCalledTimes(2); + expect(organizationSetupServiceMock.listFacilities).toHaveBeenCalledTimes(1); + }); + + it('treats an empty equipment catalogue as a successful state with explicit refresh', async () => { + organizationSetupServiceMock.listEquipmentTypes.mockReturnValue(of([])); + storeMock.targetOrganizationId.set('org-1'); + storeMock.nextStep.set('create_first_equipment'); + storeMock.steps.set([stepOf('create_first_equipment', 'pending')]); + await fixture.whenStable(); + expect(fixture.componentInstance['equipmentTypesCallState']().status).toBe('success'); + expect( + (fixture.nativeElement as HTMLElement).querySelector( + '[data-testid="onboarding-equipment-catalog-empty"]', + ), + ).not.toBeNull(); + expect(organizationSetupServiceMock.listEquipmentTypes).toHaveBeenCalledTimes(1); + }); + + it('keeps the equipment catalogue deferred during an SSR-owned render', () => { + fixture.destroy(); + storeMock.targetOrganizationId.set('org-1'); + storeMock.nextStep.set('create_first_equipment'); + storeMock.steps.set([stepOf('create_first_equipment', 'pending')]); + organizationSetupServiceMock.listEquipmentTypes.mockClear(); + const environment = createEnvironmentInjector( + [{ provide: PLATFORM_ID, useValue: 'server' }], + TestBed.inject(EnvironmentInjector), + ); + const component = createComponent(OnboardingWizardPage, { + environmentInjector: environment, + hostElement: document.createElement('div'), + }); + try { + component.changeDetectorRef.detectChanges(); + expect(organizationSetupServiceMock.listEquipmentTypes).not.toHaveBeenCalled(); + expect(component.instance['equipmentTypesCallState']().status).toBe('idle'); + } finally { + component.destroy(); + environment.destroy(); + } + }); + it('loads the onboarding record once on initialization', () => { expect(storeMock.initialize).toHaveBeenCalledOnce(); }); @@ -682,6 +813,7 @@ describe('OnboardingWizardPage', () => { expect(organizationSetupServiceMock.listFacilities).not.toHaveBeenCalled(); storeMock.targetOrganizationId.set('org-2'); + await fixture.whenStable(); fixture.componentInstance['retryCatalog'](); await fixture.whenStable(); expect(organizationSetupServiceMock.listFacilities).toHaveBeenCalledExactlyOnceWith('org-2'); @@ -790,7 +922,18 @@ describe('OnboardingWizardPage', () => { storeMock.targetOrganizationId.set('new-org'); storeMock.isCompleted.set(true); await fixture.whenStable(); - expect(routerMock.navigateByUrl).toHaveBeenCalledWith('/organizations/new-org'); + expect(routerMock.navigateByUrl).toHaveBeenCalledWith('/organizations/new-org/assets'); + }); + it('retains a deep link with client and site scope in the newly completed organization', async () => { + const destination = '/organizations/new-org/assets?customerId=customer-1&facilityId=site-1'; + vi.spyOn(TestBed.inject(ActivatedRoute).snapshot.queryParamMap, 'get').mockReturnValue( + destination, + ); + storeMock.targetOrganizationId.set('new-org'); + storeMock.isCompleted.set(true); + await fixture.whenStable(); + expect(routerMock.navigateByUrl).toHaveBeenCalledWith(destination); + expect(TestBed.inject(OrganizationLandingService).defaultDestination).not.toHaveBeenCalled(); }); it('continues through Billing for a zero-priced commercial offer', () => { storeMock.targetOrganizationId.set('org-1'); diff --git a/src/app/features/onboarding/ui/pages/onboarding-workspace-page/onboarding-workspace-page.component.ts b/src/app/features/onboarding/ui/pages/onboarding-workspace-page/onboarding-workspace-page.component.ts index 212d2a183..5ed84131a 100644 --- a/src/app/features/onboarding/ui/pages/onboarding-workspace-page/onboarding-workspace-page.component.ts +++ b/src/app/features/onboarding/ui/pages/onboarding-workspace-page/onboarding-workspace-page.component.ts @@ -5,20 +5,24 @@ import { ElementRef, ChangeDetectionStrategy, Component, + DestroyRef, computed, effect, inject, type Signal, untracked, } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { NgIcon, provideIcons } from '@ng-icons/core'; import { lucideMail, lucideClock3, lucideCircleAlert } from '@ng-icons/lucide'; +import { map, of, Subject, switchMap } from 'rxjs'; import { NOTIFICATION_CENTER_PORT } from '@features/account'; import { OtpForm } from '@features/auth/ui/forms'; import { resolveReturnUrl } from '@features/auth/utils'; import { WorkspaceStore, OnboardingStore } from '@features/onboarding/state'; import type { OrganizationJoinRequestOutput } from '@features/organization/models'; +import { OrganizationLandingService } from '@features/organization/services/organization-landing'; import { getOrganizationInitials } from '@features/organization/utils'; import { ResourceIllustration } from '@shared/resource-illustration'; import { HlmAvatarImports } from '@shared/ui/avatar'; @@ -31,8 +35,11 @@ import { HlmLarge, HlmMuted } from '@shared/ui/typography'; /** * Component OnboardingWorkspacePage * @class OnboardingWorkspacePage - * @description Orchestrates explicit workspace selection and owned request tracking in the auth split shell. + * + * @description + * Orchestrates explicit workspace selection and owned request tracking in the auth split shell. * Mailbox challenges and private discovery load browser-side and never enter the hydration payload. + * * @since 1.0.0 */ @Component({ @@ -57,80 +64,157 @@ import { HlmLarge, HlmMuted } from '@shared/ui/typography'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class OnboardingWorkspacePage { - /** Property store + /** + * Property landing * @readonly - * @description Page-scoped commands and private choices. + * + * @description + * Resolves default destinations using target organization API permissions. + * + * @access private + * @since unreleased + * + * @type {OrganizationLandingService} + */ + private readonly landing: OrganizationLandingService = inject(OrganizationLandingService); + + /** + * Property organizationNavigation + * @readonly + * + * @description + * Latest explicit workspace selection wins while target permissions are loading. + * + * @access private + * @since unreleased + * + * @type {Subject<{ organizationId: string; replaceUrl: boolean }>} + */ + private readonly organizationNavigation: Subject<{ + organizationId: string; + replaceUrl: boolean; + }> = new Subject(); + + /** + * Property store + * @readonly + * + * @description + * Page-scoped commands and private choices. + * * @access protected * @since 1.0.0 + * * @type {WorkspaceStore} */ protected readonly store: WorkspaceStore = inject(WorkspaceStore); - /** Property onboarding + /** + * Property onboarding * @readonly - * @description Existing creation remains resumable when choosing another workspace. + * + * @description + * Existing creation remains resumable when choosing another workspace. + * * @access protected * @since 1.0.0 + * * @type {OnboardingStore} */ protected readonly onboarding: OnboardingStore = inject(OnboardingStore); - /** Property host + /** + * Property host * @readonly - * @description Focuses the active proof form or the result heading after user actions. + * + * @description + * Focuses the active proof form or the result heading after user actions. + * * @access private * @since 1.0.0 + * * @type {ElementRef} */ private readonly host: ElementRef = inject(ElementRef); - /** Property router + /** + * Property router * @readonly - * @description Application navigation. + * + * @description + * Application navigation. + * * @access private * @since 1.0.0 + * * @type {Router} */ private readonly router: Router = inject(Router); - /** Property route + /** + * Property route * @readonly - * @description Validated incoming destination and display mode. + * + * @description + * Validated incoming destination and display mode. + * * @access private * @since 1.0.0 + * * @type {ActivatedRoute} */ private readonly route: ActivatedRoute = inject(ActivatedRoute); - /** Property requestsOnly + /** + * Property requestsOnly * @readonly - * @description Indicates the dedicated request-tracking route. + * + * @description + * Indicates the dedicated request-tracking route. + * * @access protected * @since 1.0.0 + * * @type {boolean} */ protected readonly requestsOnly: boolean = this.route.snapshot.data['requestsOnly'] === true; - /** Property destination + /** + * Property destination * @readonly - * @description Only the safe return destination is propagated between workflow pages. + * + * @description + * Only the safe return destination is propagated between workflow pages. + * * @access protected * @since 1.0.0 + * * @type {string} */ protected readonly destination: string = resolveReturnUrl( this.route.snapshot.queryParamMap.get('returnUrl'), '', ); - /** Property hasRequests + /** + * Property hasRequests * @readonly - * @description Whether the current account has request history. + * + * @description + * Whether the current account has request history. + * * @access protected * @since 1.0.0 + * * @type {Signal} */ protected readonly hasRequests: Signal = computed( () => (this.store.options()?.requests.length ?? 0) > 0, ); - /** Property isOrganizationsEmpty + /** + * Property isOrganizationsEmpty * @readonly - * @description Whether the "no organization available" empty state is the one being rendered, so its Create action moves into it and the footer's copy hides. + * + * @description + * Whether the "no organization available" empty state is the one being rendered, so its Create + * action moves into it and the footer's copy hides. + * * @access protected * @since 1.2.0 + * * @type {Signal} */ protected readonly isOrganizationsEmpty: Signal = computed(() => { @@ -144,11 +228,17 @@ export class OnboardingWorkspacePage { !this.store.optionsCallState().error ); }); - /** Property isRequestsEmpty + /** + * Property isRequestsEmpty * @readonly - * @description Whether the "no membership requests" empty state is the one being rendered, so its Choose-a-workspace action moves into it and the footer's copy hides. + * + * @description + * Whether the "no membership requests" empty state is the one being rendered, so its + * Choose-a-workspace action moves into it and the footer's copy hides. + * * @access protected * @since 1.2.0 + * * @type {Signal} */ protected readonly isRequestsEmpty: Signal = computed( @@ -158,23 +248,47 @@ export class OnboardingWorkspacePage { !this.hasRequests() && !this.store.optionsCallState().error, ); - /** Property getOrganizationInitials + /** + * Property getOrganizationInitials * @readonly - * @description Template-bound reference to the shared organization initials util, used for a logo-less avatar fallback. + * + * @description + * Template-bound reference to the shared organization initials util, used for a logo-less avatar + * fallback. + * * @access protected * @since 1.1.0 + * * @type {typeof getOrganizationInitials} */ protected readonly getOrganizationInitials: typeof getOrganizationInitials = getOrganizationInitials; - /** Constructor + /** + * Constructor * @constructor - * @description Loads private choices after hydration and navigates only after confirmed admission. + * + * @description + * Loads private choices after hydration and navigates only after confirmed admission. + * * @access public * @since 1.0.0 */ public constructor() { const notifications = inject(NOTIFICATION_CENTER_PORT); + this.organizationNavigation + .pipe( + switchMap(({ organizationId, replaceUrl }) => { + const explicit: string | null = this.organizationDestination(organizationId); + return (explicit ? of(explicit) : this.landing.defaultDestination(organizationId)).pipe( + map((destination) => ({ destination, replaceUrl })), + ); + }), + takeUntilDestroyed(inject(DestroyRef)), + ) + .subscribe(({ destination, replaceUrl }) => { + if (replaceUrl) void this.router.navigateByUrl(destination, { replaceUrl: true }); + else void this.router.navigateByUrl(destination); + }); afterNextRender(() => { this.store.load(); notifications.connectMercure(); @@ -211,46 +325,67 @@ export class OnboardingWorkspacePage { effect(() => { const result = this.store.admissionCallState().data; if (!result) return; - void this.router.navigateByUrl(this.organizationDestination(result.organizationId), { - replaceUrl: true, - }); + untracked(() => + this.organizationNavigation.next({ + organizationId: result.organizationId, + replaceUrl: true, + }), + ); }); } /** * Method organizationDestination - * @description Retains a safe deep link in the selected organization; guards still enforce permissions. + * + * @description + * Retains a safe deep link in the selected organization; guards still enforce permissions. + * * @access private * @since 1.0.0 + * * @param {string} organizationId - Server-authorized organization. - * @returns {string} Local destination. + * + * @returns {string | null} Explicit local destination, otherwise a permission-gated default is + * needed. */ - private organizationDestination(organizationId: string): string { + private organizationDestination(organizationId: string): string | null { const root = '/organizations/' + encodeURIComponent(organizationId); return this.destination === root || this.destination.startsWith(root + '/') || this.destination.startsWith(root + '?') ? this.destination - : root; + : null; } - /** Method open + /** + * Method open * @method open - * @description Opens an already accessible organization, causing guards to refresh activation state. + * + * @description + * Opens an already accessible organization, causing guards to refresh activation state. + * * @access protected * @since 1.0.0 + * * @param {string} organizationId - Server-authorized organization. + * * @returns {void} Starts navigation. */ protected open(organizationId: string): void { this.onboarding.clear(); - void this.router.navigateByUrl(this.organizationDestination(organizationId)); + this.organizationNavigation.next({ organizationId, replaceUrl: false }); } - /** Method statusLabel + /** + * Method statusLabel * @method statusLabel - * @description Localizes request lifecycle states without exposing raw API values. + * + * @description + * Localizes request lifecycle states without exposing raw API values. + * * @access protected * @since 1.0.0 + * * @param {OrganizationJoinRequestOutput['status']} status - Request lifecycle state. + * * @returns {string} Localized status. */ protected statusLabel(status: OrganizationJoinRequestOutput['status']): string { diff --git a/src/app/features/onboarding/ui/pages/onboarding-workspace-page/testing/onboarding-workspace-page.component.spec.ts b/src/app/features/onboarding/ui/pages/onboarding-workspace-page/testing/onboarding-workspace-page.component.spec.ts index 0c1d7873f..6b466281e 100644 --- a/src/app/features/onboarding/ui/pages/onboarding-workspace-page/testing/onboarding-workspace-page.component.spec.ts +++ b/src/app/features/onboarding/ui/pages/onboarding-workspace-page/testing/onboarding-workspace-page.component.spec.ts @@ -2,6 +2,7 @@ import { signal } from '@angular/core'; import { TestBed, type ComponentFixture } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { ActivatedRoute, convertToParamMap, provideRouter, Router } from '@angular/router'; +import { of, Subject } from 'rxjs'; import { idleCallState, successCallState, type CallState } from '@core/request-state'; import { THEME_PORT, type ThemePort } from '@core/theme'; import { NOTIFICATION_CENTER_PORT } from '@features/account'; @@ -13,6 +14,7 @@ import type { OrganizationJoinOptionsOutput, OrganizationJoinRequestOutput, } from '@features/organization/models'; +import { OrganizationLandingService } from '@features/organization/services/organization-landing'; import { OnboardingWorkspacePage } from '../onboarding-workspace-page.component'; describe('OnboardingWorkspacePage', () => { @@ -56,9 +58,19 @@ describe('OnboardingWorkspacePage', () => { }; const onboarding = { clear: vi.fn(), targetOrganizationId: signal(null) }; const notifications = { revision: signal(0), connectMercure: vi.fn() }; + const landing = { + defaultDestination: vi.fn((organizationId: string) => + of(`/organizations/${organizationId}/assets`), + ), + }; beforeEach(() => { vi.clearAllMocks(); + landing.defaultDestination + .mockReset() + .mockImplementation((organizationId: string) => + of(`/organizations/${organizationId}/assets`), + ); workspace.options.set(null); workspace.optionsCallState.set(idleCallState()); workspace.loading.set(false); @@ -90,6 +102,7 @@ describe('OnboardingWorkspacePage', () => { providers: [ provideRouter([]), { provide: OnboardingStore, useValue: onboarding }, + { provide: OrganizationLandingService, useValue: landing }, { provide: NOTIFICATION_CENTER_PORT, useValue: notifications }, { provide: THEME_PORT, @@ -158,8 +171,9 @@ describe('OnboardingWorkspacePage', () => { '/organizations/org-1/equipments?filter=overdue', ], ['/organizations/org-1?tab=overview', '/organizations/org-1?tab=overview'], - ['/organizations/org-10/equipments', '/organizations/org-1'], - ['https://external.example/redirect', '/organizations/org-1'], + ['/organizations/org-10/equipments', '/organizations/org-1/assets'], + ['https://external.example/redirect', '/organizations/org-1/assets'], + ['', '/organizations/org-1/assets'], ])( 'navigates only within the admitted organization for destination %s', async (destination, expected) => { @@ -201,6 +215,18 @@ describe('OnboardingWorkspacePage', () => { expect(TestBed.inject(Router).navigateByUrl).toHaveBeenCalledWith('/organizations/org-1'); expect(workspace.admit).not.toHaveBeenCalled(); }); + it('ignores a late destination from a superseded workspace selection', async () => { + const fixture = await renderPage(); + const obsolete = new Subject(); + landing.defaultDestination.mockReturnValueOnce(obsolete); + fixture.componentInstance['open']('org-1'); + fixture.componentInstance['open']('org-2'); + obsolete.next('/organizations/org-1/assets'); + expect(TestBed.inject(Router).navigateByUrl).toHaveBeenCalledTimes(1); + expect(TestBed.inject(Router).navigateByUrl).toHaveBeenCalledWith( + '/organizations/org-2/assets', + ); + }); it('renders request lifecycle labels and delegates cancellation without granting access', async () => { workspace.options.set({ diff --git a/src/app/features/organization/FEATURE.md b/src/app/features/organization/FEATURE.md index c5233a00a..197e010a1 100644 --- a/src/app/features/organization/FEATURE.md +++ b/src/app/features/organization/FEATURE.md @@ -17,6 +17,11 @@ The published setup facade accepts optional server session/item keys for one dur a time. Organization, invitations, facilities and equipment retain their owning transports; setup replays never bypass permissions or the server's transactional quotas. +Setup also publishes the complete active equipment catalogue as `SetupEquipmentTypeOption` +choices through `OrganizationSetupService.listEquipmentTypes`. Equipment retains transport, +archive rules and label/icon normalization; onboarding consumes only the setup DTO and never +Equipment private stores or a hardcoded selectable catalogue. Prepared creation payloads stay unchanged. + Access policy mode/role drafts reset only after a successful policy save or organization change. Domain verification and refreshes never discard edits. Admission and invitation commands own typed feedback and membership invalidation in their stores, with one notification per result. @@ -25,6 +30,20 @@ feedback and membership invalidation in their stores, with one notification per Owns organization context and organization-scoped business workflows. +FireGuard is specialized in fire-equipment park management. The Park explorer is +the default permitted entry point; the historical Dashboard route remains available. +The organization profile adapts the starting context, while API permissions alone +grant access. Internal customers belong to one organization and are optional for operators. + +The Park and Dashboard share the explicit fire/all, customer and site scope. Their +unavailable equipment, controls to prepare and unresolved anomalies queues use +server counts and open the matching filtered Park list when site-read permission grants +that route; otherwise authorized counts remain read-only. A site includes its descendants +by default. The explorer's direct scope applies to equipment, controls and anomaly rows, +including their totals, retries and subsequent pages. +An unavailable count is never presented as zero. The compliance register retains +its own scope and does not imply that the Park filters changed an archived register. + This feature is responsible for: - active organization context (default-workspace resolution and the persisted last-organization preference), @@ -367,6 +386,13 @@ See the [organization ui reference](../../../../docs/guides/organization-and-acc `InspectionStatusTag` components. Read-only — the parent previews, neither subfeature's own management surface or state is touched. + The Park also consumes Equipment's public `state` catalogue store, `options` + and `utils` for server-authorized type labels and equipment titles; its + `ui/components` barrel publishes `EquipmentStatusTag`. Inspection's published + `ui/dataviews/non-conformity-list` displays the server-filtered anomaly queue + with source-inspection and equipment links, without offering anomaly writes. + The Customers `ui/components` picker supplies optional internal-client context. + The two components were added to this contract deliberately: the pane previously printed `item.status` and `item.result` raw, so the same equipment showed `in_progress` here and a localized, coloured tag everywhere else. The @@ -526,9 +552,10 @@ semantics without injecting `OrganizationPermissionService` into the shell. These narrow entry points are published to the named consumers. `app` denotes the application composition root. Standard concern barrels follow ARCHITECTURE.md; prose examples do not grant access. -| Entry point | Consumers | -| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `providers/bootstrap` | `app` | -| `services/member-presence` | `organization/features/collaboration` | -| `ui/components/member-presence-indicator` | `account`, `organization/features/collaboration` | -| `services/browser-download` | `organization/features/equipments`, `organization/features/facilities`, `organization/features/imports`, `organization/features/inspections`, `organization/features/maintenance-schedules` | +| Entry point | Consumers | +| ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `services/organization-landing` | `onboarding` | +| `providers/bootstrap` | `app` | +| `services/member-presence` | `organization/features/collaboration` | +| `ui/components/member-presence-indicator` | `account`, `organization/features/collaboration` | +| `services/browser-download` | `organization/features/equipments`, `organization/features/facilities`, `organization/features/imports`, `organization/features/inspections`, `organization/features/maintenance-schedules`, `organization/features/maintenance-exports` | diff --git a/src/app/features/organization/data-access/index.ts b/src/app/features/organization/data-access/index.ts index c84e7918d..07b428223 100644 --- a/src/app/features/organization/data-access/index.ts +++ b/src/app/features/organization/data-access/index.ts @@ -8,3 +8,5 @@ export { ComplianceService } from './services/compliance/compliance.service'; export { TeamService } from './services/team/team.service'; export { OrganizationAccessService } from './services/organization-access/organization-access.service'; export { PresenceService } from './services/presence'; + +export { ParkService } from './services/park/park.service'; diff --git a/src/app/features/organization/data-access/services/park/park.service.ts b/src/app/features/organization/data-access/services/park/park.service.ts new file mode 100644 index 000000000..23ee2384a --- /dev/null +++ b/src/app/features/organization/data-access/services/park/park.service.ts @@ -0,0 +1,72 @@ +import { Service } from '@angular/core'; +import type { Observable } from 'rxjs'; +import { HydraApiService } from '@core/api'; +import type { HydraCollection, RequestOptions } from '@core/api/models'; +import type { NonConformityOutput } from '@features/organization/features/inspections/models'; +import type { ParkAnomaliesSummaryOutput } from '@features/organization/models'; + +/** + * Class ParkService + * @class ParkService + * + * @description + * Organization-owned read transport for the specialized park action queues. + * + * @since unreleased + */ +@Service() +export class ParkService extends HydraApiService { + //#region Methods + /** + * Method anomaliesSummary + * @method + * + * @description + * Reads unresolved anomaly counts with explicit equipment family, customer and site filters. + * + * @access public + * @since unreleased + * + * @param {string} organizationId - Owning organization used as the authority for every scoped + * request. + * @param {RequestOptions} options - Value supplied by the owning park workflow. + * + * @returns {Observable} Result consumed by the owning park workflow. + */ + public anomaliesSummary( + organizationId: string, + options?: RequestOptions, + ): Observable { + return this.getOne( + `/api/organizations/${organizationId}/park-anomalies-summary`, + options, + ); + } + /** + * Method anomalies + * @method + * + * @description + * Reads one exact page of published open or in-progress anomalies in the retained park scope. + * + * @access public + * @since unreleased + * + * @param {string} organizationId - Owning organization used as the authority for every scoped + * request. + * @param {RequestOptions} options - Value supplied by the owning park workflow. + * + * @returns {Observable>} Result consumed by the owning park + * workflow. + */ + public anomalies( + organizationId: string, + options?: RequestOptions, + ): Observable> { + return this.getCollection( + `/api/organizations/${organizationId}/park-anomalies`, + options, + ); + } + //#endregion +} diff --git a/src/app/features/organization/data-access/services/park/testing/park.service.spec.ts b/src/app/features/organization/data-access/services/park/testing/park.service.spec.ts new file mode 100644 index 000000000..3be7e28bb --- /dev/null +++ b/src/app/features/organization/data-access/services/park/testing/park.service.spec.ts @@ -0,0 +1,39 @@ +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { ENV_CONFIG } from '@core/config/environment/env.token'; +import { ParkService } from '../park.service'; + +describe('ParkService', () => { + let service: ParkService; + let http: HttpTestingController; + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + ParkService, + { provide: ENV_CONFIG, useValue: { apiUrl: 'https://api.test' } }, + ], + }); + service = TestBed.inject(ParkService); + http = TestBed.inject(HttpTestingController); + }); + afterEach(() => http.verify()); + it('forwards exactly the same scope to counts and the paginated anomaly register', () => { + const params = { family: 'fire', customerId: 'customer', facilityId: 'site' }; + service.anomaliesSummary('org', { params }).subscribe(); + const summary = http.expectOne((request) => request.url.endsWith('/park-anomalies-summary')); + expect(summary.request.params.get('customerId')).toBe('customer'); + expect(summary.request.params.get('family')).toBe('fire'); + expect(summary.request.params.get('facilityId')).toBe('site'); + summary.flush({ openAnomalies: 1, bySeverity: { low: 1, medium: 0, high: 0, critical: 0 } }); + service.anomalies('org', { params, page: 3, itemsPerPage: 50 }).subscribe(); + const list = http.expectOne((request) => request.url.endsWith('/park-anomalies')); + expect(list.request.params.get('customerId')).toBe('customer'); + expect(list.request.params.get('family')).toBe('fire'); + expect(list.request.params.get('facilityId')).toBe('site'); + expect(list.request.params.get('page')).toBe('3'); + list.flush({ member: [], totalItems: 1 }); + }); +}); diff --git a/src/app/features/organization/features/customers/FEATURE.md b/src/app/features/organization/features/customers/FEATURE.md new file mode 100644 index 000000000..83ed4b648 --- /dev/null +++ b/src/app/features/organization/features/customers/FEATURE.md @@ -0,0 +1,39 @@ +# Customers Feature + +Owns internal customer identity and contacts within an organization. These records do not +grant external access and remain separate from offline replay client identifiers. + +## Entry points + +- `CUSTOMER_ROUTES`: `/organizations/:organizationId/customers`. +- `models`: `CustomerOutput`, `CustomerInput`, `CustomerContact`. +- `data-access`: `CustomerService`; revision-checked update, archive and restore. +- `ui/components`: `CustomerPicker`, an explicitly authorized business widget with its + own scoped store. Facilities use this public API for optional root-site assignment. + +The directory searches and paginates on the server. Active and archived views remain +separate. Administration is online; drafts survive server and connectivity failures. +Page and picker reads are browser-only, with no authenticated TransferState handoff. +Accepted writes cannot be cancelled by a second click. Organization changes cancel reads +and reject feedback from the old scope. + +Archived records remain readable and retain historical site assignments. The picker +hydrates an existing selection independently of search pages and only accepts active +candidates for a new assignment. Root-site ownership is enforced by the Facility API. + +Organization permissions `organization.customers.read` and `organization.customers.manage` +gate directory reads and mutation affordances. No portal, cross-organization sharing, +commercial invoicing or bulk export is owned here. + +Forms emit normalized input; pages own commands, navigation and editor dismissal. +`customerStoreEvents.saved` reports the immutable organization and record identity. + +## Public entry points + +| Entry point | Consumers | +| ------------- | ------------------------------------------- | +| `data-access` | `organization/features/maintenance-exports` | +| `models` | `organization/features/maintenance-exports` | + +Maintenance exports may read named reference candidates only with customers.read. +Its source picker otherwise uses the minimal retained identity of the publication. diff --git a/src/app/features/organization/features/customers/customers.routes.ts b/src/app/features/organization/features/customers/customers.routes.ts new file mode 100644 index 000000000..6434e3aca --- /dev/null +++ b/src/app/features/organization/features/customers/customers.routes.ts @@ -0,0 +1,25 @@ +import type { Routes } from '@angular/router'; +import { DASHBOARD_MOBILE_NAVIGATION_ROOT_DATA_KEY, type DashboardRouteData } from '@core/routing'; + +/** + * Constant CUSTOMER_ROUTES + * + * @description + * Organization owns the entry permission guard; customers own their directory route. + * + * @since unreleased + */ +export const CUSTOMER_ROUTES: Routes = [ + { + path: '', + loadComponent: () => + import('./ui/pages/customers-page/customers-page.component').then( + (module) => module.CustomersPage, + ), + title: $localize`:@@route.customers:Customers`, + data: { + breadcrumb: $localize`:@@route.customers:Customers`, + [DASHBOARD_MOBILE_NAVIGATION_ROOT_DATA_KEY]: true, + } satisfies DashboardRouteData, + }, +]; diff --git a/src/app/features/organization/features/customers/data-access/index.ts b/src/app/features/organization/features/customers/data-access/index.ts new file mode 100644 index 000000000..31c48ca21 --- /dev/null +++ b/src/app/features/organization/features/customers/data-access/index.ts @@ -0,0 +1 @@ +export { CustomerService } from './services/customer/customer.service'; diff --git a/src/app/features/organization/features/customers/data-access/services/customer/customer.service.ts b/src/app/features/organization/features/customers/data-access/services/customer/customer.service.ts new file mode 100644 index 000000000..ede59f398 --- /dev/null +++ b/src/app/features/organization/features/customers/data-access/services/customer/customer.service.ts @@ -0,0 +1,164 @@ +import { Service } from '@angular/core'; +import type { Observable } from 'rxjs'; +import { HydraApiService } from '@core/api'; +import type { HydraCollection, RequestOptions } from '@core/api/models'; +import type { + CustomerInput, + CustomerOutput, +} from '@features/organization/features/customers/models'; + +/** + * Class CustomerService + * @class CustomerService + * + * @description + * Hydra transport for internal customers with revision-checked mutations. + * + * @since unreleased + */ +@Service() +export class CustomerService extends HydraApiService { + //#region Methods + /** + * Method list + * @method + * + * @description + * Reads a server-paginated customer directory. + * + * @access public + * @since unreleased + * + * @param {string} organizationId - Owning organization used as the authority for the request. + * @param {RequestOptions} options - Value supplied by the owning customer workflow. + * + * @returns {Observable>} Result for the owning customer workflow. + */ + public list( + organizationId: string, + options?: RequestOptions, + ): Observable> { + return this.getCollection( + `/api/organizations/${organizationId}/customers`, + options, + ); + } + + /** + * Method get + * @method + * + * @description + * Reads the selected record even when it is archived or outside the current page. + * + * @access public + * @since unreleased + * + * @param {string} organizationId - Owning organization used as the authority for the request. + * @param {string} customerId - Value supplied by the owning customer workflow. + * + * @returns {Observable} Result for the owning customer workflow. + */ + public get(organizationId: string, customerId: string): Observable { + return this.getOne( + `/api/organizations/${organizationId}/customers/${customerId}`, + ); + } + + /** + * Method create + * @method + * + * @description + * Creates an internal customer. + * + * @access public + * @since unreleased + * + * @param {string} organizationId - Owning organization used as the authority for the request. + * @param {CustomerInput} input - Value supplied by the owning customer workflow. + * + * @returns {Observable} Result for the owning customer workflow. + */ + public create(organizationId: string, input: CustomerInput): Observable { + return this.post( + `/api/organizations/${organizationId}/customers`, + input, + ); + } + + /** + * Method update + * @method + * + * @description + * Updates a customer only against the displayed revision. + * + * @access public + * @since unreleased + * + * @param {string} organizationId - Owning organization used as the authority for the request. + * @param {CustomerOutput} customer - Value supplied by the owning customer workflow. + * @param {CustomerInput} input - Value supplied by the owning customer workflow. + * + * @returns {Observable} Result for the owning customer workflow. + */ + public update( + organizationId: string, + customer: CustomerOutput, + input: CustomerInput, + ): Observable { + return this.patch( + `/api/organizations/${organizationId}/customers/${customer.id}`, + input, + { headers: { 'If-Match': `"revision-${customer.revision}"` } }, + ); + } + + /** + * Method archive + * @method + * + * @description + * Archives an internal customer while preserving existing site links. + * + * @access public + * @since unreleased + * + * @param {string} organizationId - Owning organization used as the authority for the request. + * @param {CustomerOutput} customer - Value supplied by the owning customer workflow. + * + * @returns {Observable} Result for the owning customer workflow. + */ + public archive(organizationId: string, customer: CustomerOutput): Observable { + return this.post, CustomerOutput>( + `/api/organizations/${organizationId}/customers/${customer.id}/archive`, + {}, + { headers: { 'If-Match': `"revision-${customer.revision}"` } }, + ); + } + + /** + * Method restore + * @method + * + * @description + * Restores a customer for new assignments. + * + * @access public + * @since unreleased + * + * @param {string} organizationId - Owning organization used as the authority for the request. + * @param {CustomerOutput} customer - Value supplied by the owning customer workflow. + * + * @returns {Observable} Result for the owning customer workflow. + */ + public restore(organizationId: string, customer: CustomerOutput): Observable { + return this.post, CustomerOutput>( + `/api/organizations/${organizationId}/customers/${customer.id}/restore`, + {}, + { headers: { 'If-Match': `"revision-${customer.revision}"` } }, + ); + } + //#endregion +} diff --git a/src/app/features/organization/features/customers/data-access/services/customer/testing/customer.service.spec.ts b/src/app/features/organization/features/customers/data-access/services/customer/testing/customer.service.spec.ts new file mode 100644 index 000000000..c1681e6d9 --- /dev/null +++ b/src/app/features/organization/features/customers/data-access/services/customer/testing/customer.service.spec.ts @@ -0,0 +1,91 @@ +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { ENV_CONFIG } from '@core/config/environment/env.token'; +import type { CustomerOutput } from '@features/organization/features/customers/models'; +import { CustomerService } from '../customer.service'; + +describe('CustomerService', () => { + let service: CustomerService; + let http: HttpTestingController; + const url = 'https://api.test/api/organizations/org/customers'; + const customer: CustomerOutput = { + '@id': url + '/customer', + '@type': 'Customer', + id: 'customer', + organizationId: 'org', + name: 'North Hospital', + contacts: [], + revision: 3, + createdAt: '2026-10-06T10:00:00Z', + updatedAt: '2026-10-06T10:00:00Z', + }; + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + CustomerService, + { provide: ENV_CONFIG, useValue: { apiUrl: 'https://api.test' } }, + ], + }); + service = TestBed.inject(CustomerService); + http = TestBed.inject(HttpTestingController); + }); + afterEach(() => http.verify()); + + it('searches and paginates the authorized active directory', () => { + service + .list('org', { page: 2, itemsPerPage: 20, search: 'hospital', params: { archived: false } }) + .subscribe(); + const request = http.expectOne((candidate) => candidate.url === url); + expect(request.request.params.get('search')).toBe('hospital'); + expect(request.request.params.get('page')).toBe('2'); + expect(request.request.params.get('archived')).toBe('false'); + expect(request.request.withCredentials).toBe(true); + request.flush({ member: [], totalItems: 0 }); + }); + it('creates contacts without confusing customer identities with offline client ids', () => { + const input = { + name: 'Hospital', + contacts: [ + { name: 'Safety manager', role: 'Safety', phone: null, email: 'safety@example.test' }, + ], + }; + service.create('org', input).subscribe(); + const request = http.expectOne(url); + expect(request.request.method).toBe('POST'); + expect(request.request.body).toEqual(input); + expect(request.request.body.clientId).toBeUndefined(); + request.flush(customer); + }); + it('sends explicit clears and the quoted revision on update', () => { + service.update('org', customer, { name: 'Hospital', email: null, contacts: [] }).subscribe(); + const request = http.expectOne(url + '/customer'); + expect(request.request.method).toBe('PATCH'); + expect(request.request.headers.get('If-Match')).toBe('"revision-3"'); + expect(request.request.headers.get('Content-Type')).toBe('application/merge-patch+json'); + expect(request.request.body.email).toBeNull(); + request.flush(customer); + }); + it('archives and restores with revision checks and propagates stale conflicts', () => { + let failure: unknown; + service.archive('org', customer).subscribe({ + error: (error: unknown) => { + failure = error; + }, + }); + const archive = http.expectOne(url + '/customer/archive'); + expect(archive.request.headers.get('If-Match')).toBe('"revision-3"'); + archive.flush( + { title: 'Revision conflict', detail: 'Reload before retrying.' }, + { status: 412, statusText: 'Precondition Failed' }, + ); + expect(failure).toMatchObject({ status: 412, detail: 'Reload before retrying.' }); + service.restore('org', customer).subscribe(); + const restore = http.expectOne(url + '/customer/restore'); + expect(restore.request.method).toBe('POST'); + expect(restore.request.headers.get('If-Match')).toBe('"revision-3"'); + restore.flush(customer); + }); +}); diff --git a/src/app/features/organization/features/customers/models/customer/customer-input.interface.ts b/src/app/features/organization/features/customers/models/customer/customer-input.interface.ts new file mode 100644 index 000000000..1836232ec --- /dev/null +++ b/src/app/features/organization/features/customers/models/customer/customer-input.interface.ts @@ -0,0 +1,78 @@ +import type { CustomerContact } from './customer-output.interface'; + +/** + * Interface CustomerInput + * @interface + * + * @description + * Writable customer fields; optional empty values explicitly clear existing information. + * + * @since unreleased + */ +export interface CustomerInput { + /** + * Property name + * @readonly + * + * @description + * Customer display name. + * + * @access public + * @since unreleased + * + * @type {string} + */ + readonly name: string; + /** + * Property code + * @readonly + * + * @description + * Optional internal reference. + * + * @access public + * @since unreleased + * + * @type {string | null} + */ + readonly code?: string | null; + /** + * Property email + * @readonly + * + * @description + * Optional general email. + * + * @access public + * @since unreleased + * + * @type {string | null} + */ + readonly email?: string | null; + /** + * Property phone + * @readonly + * + * @description + * Optional general telephone. + * + * @access public + * @since unreleased + * + * @type {string | null} + */ + readonly phone?: string | null; + /** + * Property contacts + * @readonly + * + * @description + * Internal contacts without independent identities. + * + * @access public + * @since unreleased + * + * @type {readonly CustomerContact[]} + */ + readonly contacts?: readonly CustomerContact[]; +} diff --git a/src/app/features/organization/features/customers/models/customer/customer-output.interface.ts b/src/app/features/organization/features/customers/models/customer/customer-output.interface.ts new file mode 100644 index 000000000..a9653a740 --- /dev/null +++ b/src/app/features/organization/features/customers/models/customer/customer-output.interface.ts @@ -0,0 +1,220 @@ +import type { HydraItem } from '@core/api/models'; + +/** + * Interface CustomerContact + * @interface + * + * @description + * Internal contact retained with an organization's customer record. + * + * @since unreleased + */ +export interface CustomerContact { + /** + * Property name + * @readonly + * + * @description + * Contact name. + * + * @access public + * @since unreleased + * + * @type {string} + */ + readonly name: string; + /** + * Property email + * @readonly + * + * @description + * Optional contact email. + * + * @access public + * @since unreleased + * + * @type {string | null} + */ + readonly email?: string | null; + /** + * Property phone + * @readonly + * + * @description + * Optional telephone number. + * + * @access public + * @since unreleased + * + * @type {string | null} + */ + readonly phone?: string | null; + /** + * Property role + * @readonly + * + * @description + * Optional responsibility within the customer organization. + * + * @access public + * @since unreleased + * + * @type {string | null} + */ + readonly role?: string | null; +} + +/** + * Interface CustomerOutput + * @interface + * + * @description + * Organization-scoped customer; this record grants no external account access. + * + * @since unreleased + */ +export interface CustomerOutput extends HydraItem { + /** + * Property id + * @readonly + * + * @description + * Stable record identity. + * + * @access public + * @since unreleased + * + * @type {string} + */ + readonly id: string; + /** + * Property organizationId + * @readonly + * + * @description + * Owning organization. + * + * @access public + * @since unreleased + * + * @type {string} + */ + readonly organizationId: string; + /** + * Property name + * @readonly + * + * @description + * Display name. + * + * @access public + * @since unreleased + * + * @type {string} + */ + readonly name: string; + /** + * Property code + * @readonly + * + * @description + * Optional internal reference. + * + * @access public + * @since unreleased + * + * @type {string | null} + */ + readonly code?: string | null; + /** + * Property email + * @readonly + * + * @description + * General email. + * + * @access public + * @since unreleased + * + * @type {string | null} + */ + readonly email?: string | null; + /** + * Property phone + * @readonly + * + * @description + * General telephone. + * + * @access public + * @since unreleased + * + * @type {string | null} + */ + readonly phone?: string | null; + /** + * Property contacts + * @readonly + * + * @description + * Internal contact directory. + * + * @access public + * @since unreleased + * + * @type {readonly CustomerContact[]} + */ + readonly contacts: readonly CustomerContact[]; + /** + * Property archivedAt + * @readonly + * + * @description + * Archive instant; omitted or null for active customers. + * + * @access public + * @since unreleased + * + * @type {string | null} + */ + readonly archivedAt?: string | null; + /** + * Property createdAt + * @readonly + * + * @description + * Creation instant. + * + * @access public + * @since unreleased + * + * @type {string} + */ + readonly createdAt: string; + /** + * Property updatedAt + * @readonly + * + * @description + * Last update instant. + * + * @access public + * @since unreleased + * + * @type {string} + */ + readonly updatedAt: string; + /** + * Property revision + * @readonly + * + * @description + * Revision required for concurrent writes. + * + * @access public + * @since unreleased + * + * @type {number} + */ + readonly revision: number; +} diff --git a/src/app/features/organization/features/customers/models/index.ts b/src/app/features/organization/features/customers/models/index.ts new file mode 100644 index 000000000..f68714d72 --- /dev/null +++ b/src/app/features/organization/features/customers/models/index.ts @@ -0,0 +1,2 @@ +export type { CustomerOutput, CustomerContact } from './customer/customer-output.interface'; +export type { CustomerInput } from './customer/customer-input.interface'; diff --git a/src/app/features/organization/features/customers/state/customer/customer.store.ts b/src/app/features/organization/features/customers/state/customer/customer.store.ts new file mode 100644 index 000000000..65346e908 --- /dev/null +++ b/src/app/features/organization/features/customers/state/customer/customer.store.ts @@ -0,0 +1,364 @@ +import { computed, inject } from '@angular/core'; +import { tapResponse } from '@ngrx/operators'; +import { patchState, signalStore, type, withComputed, withMethods, withState } from '@ngrx/signals'; +import { removeAllEntities, setAllEntities, withEntities } from '@ngrx/signals/entities'; +import { Dispatcher } from '@ngrx/signals/events'; +import { rxMethod } from '@ngrx/signals/rxjs-interop'; +import { EMPTY, exhaustMap, pipe, switchMap, type Observable } from 'rxjs'; +import { + idleCallState, + pendingCallState, + successCallState, + errorCallState, + toStoreError, + type CallState, +} from '@core/request-state'; +import { CustomerService } from '@features/organization/features/customers/data-access'; +import type { + CustomerInput, + CustomerOutput, +} from '@features/organization/features/customers/models'; +import { customerStoreEvents } from './events/events'; + +/** + * Interface CustomerQuery + * @interface + * + * @description + * Server directory query with one explicit organization authority. + * + * @since unreleased + */ +export interface CustomerQuery { + /** + * Property organizationId + * @readonly + * + * @description + * Owning organization used as the authority for the request. + * + * @access public + * @since unreleased + * + * @type {string} + */ + readonly organizationId: string; + /** + * Property page + * @readonly + * + * @description + * Requested server page, starting at one. + * + * @access public + * @since unreleased + * + * @type {number} + */ + readonly page?: number; + /** + * Property search + * @readonly + * + * @description + * Server search term across the authorized customer directory. + * + * @access public + * @since unreleased + * + * @type {string} + */ + readonly search?: string; + /** + * Property archived + * @readonly + * + * @description + * Whether the server query selects archived customers only. + * + * @access public + * @since unreleased + * + * @type {boolean} + */ + readonly archived?: boolean; +} + +/** + * Type CustomerCommand + * + * @description + * Accepted write; repeated clicks cannot cancel a command already in flight. + * + * @since unreleased + * + * @type + */ +export type CustomerCommand = + | { readonly kind: 'create'; readonly organizationId: string; readonly input: CustomerInput } + | { + readonly kind: 'update'; + readonly organizationId: string; + readonly customer: CustomerOutput; + readonly input: CustomerInput; + } + | { + readonly kind: 'archive' | 'restore'; + readonly organizationId: string; + readonly customer: CustomerOutput; + }; + +/** + * Interface CustomerState + * @interface + * + * @description + * Directory, selected-record and command states belong to the component's lifetime. + * + * @since unreleased + */ +interface CustomerState { + /** + * Property query + * @readonly + * + * @description + * Current organization and committed directory query. + * + * @access public + * @since unreleased + * + * @type {CustomerQuery | null} + */ + readonly query: CustomerQuery | null; + /** + * Property listCallState + * @readonly + * + * @description + * Request state for the server-paginated directory. + * + * @access public + * @since unreleased + * + * @type {CallState} + */ + readonly listCallState: CallState; + /** + * Property readCallState + * @readonly + * + * @description + * Request state for the retained selected customer. + * + * @access public + * @since unreleased + * + * @type {CallState} + */ + readonly readCallState: CallState; + /** + * Property writeCallState + * @readonly + * + * @description + * Request state for the accepted customer mutation. + * + * @access public + * @since unreleased + * + * @type {CallState} + */ + readonly writeCallState: CallState; + /** + * Property total + * @readonly + * + * @description + * Authoritative result count from the server. + * + * @access public + * @since unreleased + * + * @type {number} + */ + readonly total: number; +} + +/** + * Constant initialState + * + * @description + * Empty state never implies that the server directory was successfully read. + * + * @since unreleased + */ +const initialState: CustomerState = { + query: null, + listCallState: idleCallState(), + readCallState: idleCallState(), + writeCallState: idleCallState(), + total: 0, +}; + +/** + * Constant CustomerStore + * + * @description + * Customer directory with cancellable scoped reads and non-cancellable accepted writes. + * + * @since unreleased + */ +export const CustomerStore = signalStore( + withEntities({ entity: type(), collection: 'customer' }), + withState(initialState), + withComputed((store) => ({ + pageCount: computed(() => Math.max(1, Math.ceil(store.total() / 20))), + })), + withMethods((store, service = inject(CustomerService)) => ({ + /** + * @description + * Queries one server page and cancels obsolete reads on scope changes. + */ + load: rxMethod( + pipe( + switchMap((query) => { + if (!query) { + patchState(store, initialState, removeAllEntities({ collection: 'customer' }), { + writeCallState: + store.writeCallState().status === 'pending' + ? store.writeCallState() + : idleCallState(), + }); + return EMPTY; + } + const sameOrganization = store.query()?.organizationId === query.organizationId; + patchState( + store, + ...(sameOrganization + ? [] + : [initialState, removeAllEntities({ collection: 'customer' })]), + { + query, + listCallState: pendingCallState(), + writeCallState: + store.writeCallState().status === 'pending' + ? store.writeCallState() + : sameOrganization + ? store.writeCallState() + : idleCallState(), + }, + ); + return service + .list(query.organizationId, { + page: query.page ?? 1, + itemsPerPage: 20, + search: query.search, + params: { archived: query.archived ?? false }, + }) + .pipe( + tapResponse({ + next: (response) => + patchState( + store, + setAllEntities([...response.member], { collection: 'customer' }), + { + total: response.totalItems, + listCallState: successCallState(null), + }, + ), + error: (error: unknown) => + patchState(store, { listCallState: errorCallState(toStoreError(error)) }), + }), + ); + }), + ), + ), + /** + * @description + * Hydrates a retained selection independently of directory pagination. + */ + read: rxMethod<{ readonly organizationId: string; readonly customerId: string } | null>( + pipe( + switchMap((scope) => { + patchState(store, { readCallState: scope ? pendingCallState() : idleCallState() }); + if (!scope) return EMPTY; + return service.get(scope.organizationId, scope.customerId).pipe( + tapResponse({ + next: (customer) => patchState(store, { readCallState: successCallState(customer) }), + error: (error: unknown) => + patchState(store, { readCallState: errorCallState(toStoreError(error)) }), + }), + ); + }), + ), + ), + /** + * @description + * Resets only stale command feedback when a new editor is opened. + */ + clearWrite(): void { + if (store.writeCallState().status !== 'pending') + patchState(store, { writeCallState: idleCallState() }); + }, + })), + withMethods((store, service = inject(CustomerService), dispatcher = inject(Dispatcher)) => ({ + /** + * @description + * Persists one accepted command and reports its immutable originating scope. + */ + save: rxMethod( + pipe( + exhaustMap((command) => { + patchState(store, { writeCallState: pendingCallState() }); + let request: Observable; + if (command.kind === 'create') + request = service.create(command.organizationId, command.input); + else if (command.kind === 'update') + request = service.update(command.organizationId, command.customer, command.input); + else if (command.kind === 'archive') + request = service.archive(command.organizationId, command.customer); + else request = service.restore(command.organizationId, command.customer); + return request.pipe( + tapResponse({ + next: (customer) => { + patchState(store, { + writeCallState: + store.query()?.organizationId === command.organizationId + ? successCallState(customer) + : idleCallState(), + }); + dispatcher.dispatch( + customerStoreEvents.saved({ + organizationId: command.organizationId, + customerId: customer.id, + }), + ); + }, + error: (error: unknown) => { + patchState(store, { + writeCallState: + store.query()?.organizationId === command.organizationId + ? errorCallState(toStoreError(error)) + : idleCallState(), + }); + }, + }), + ); + }), + ), + ), + })), +); + +/** + * Type CustomerStoreType + * + * @description + * Public instance type for orchestrating consumers. + * + * @since unreleased + * + * @type + */ +export type CustomerStoreType = InstanceType; diff --git a/src/app/features/organization/features/customers/state/customer/events/events.ts b/src/app/features/organization/features/customers/state/customer/events/events.ts new file mode 100644 index 000000000..42b1e0d35 --- /dev/null +++ b/src/app/features/organization/features/customers/state/customer/events/events.ts @@ -0,0 +1,17 @@ +import { type } from '@ngrx/signals'; +import { eventGroup } from '@ngrx/signals/events'; + +/** + * Constant customerStoreEvents + * + * @description + * Confirmed mutations invalidate directory consumers and dismiss successful editors. + * + * @since unreleased + */ +export const customerStoreEvents = eventGroup({ + source: 'Customer Store', + events: { + saved: type<{ readonly organizationId: string; readonly customerId: string }>(), + }, +}); diff --git a/src/app/features/organization/features/customers/state/customer/index.ts b/src/app/features/organization/features/customers/state/customer/index.ts new file mode 100644 index 000000000..fe0b58e72 --- /dev/null +++ b/src/app/features/organization/features/customers/state/customer/index.ts @@ -0,0 +1,3 @@ +export { CustomerStore } from './customer.store'; +export type { CustomerStoreType, CustomerQuery, CustomerCommand } from './customer.store'; +export { customerStoreEvents } from './events/events'; diff --git a/src/app/features/organization/features/customers/state/customer/testing/customer.store.spec.ts b/src/app/features/organization/features/customers/state/customer/testing/customer.store.spec.ts new file mode 100644 index 000000000..4bc299de9 --- /dev/null +++ b/src/app/features/organization/features/customers/state/customer/testing/customer.store.spec.ts @@ -0,0 +1,104 @@ +import { TestBed } from '@angular/core/testing'; +import { of, Subject, throwError } from 'rxjs'; +import type { HydraCollection } from '@core/api/models'; +import { CustomerService } from '@features/organization/features/customers/data-access'; +import type { CustomerOutput } from '@features/organization/features/customers/models'; +import { CustomerStore, type CustomerStoreType } from '../customer.store'; + +describe('CustomerStore', () => { + const customer: CustomerOutput = { + '@id': 'customer', + '@type': 'Customer', + id: 'customer', + organizationId: 'org', + name: 'Hospital', + contacts: [], + revision: 1, + createdAt: '2026-10-06T10:00:00Z', + updatedAt: '2026-10-06T10:00:00Z', + }; + const directory: HydraCollection = { + '@id': 'customers', + '@type': 'Collection', + member: [customer], + totalItems: 35, + }; + let store: CustomerStoreType; + let service: { + list: ReturnType; + get: ReturnType; + create: ReturnType; + update: ReturnType; + archive: ReturnType; + restore: ReturnType; + }; + beforeEach(() => { + service = { + list: vi.fn().mockReturnValue(of(directory)), + get: vi.fn().mockReturnValue(of(customer)), + create: vi.fn().mockReturnValue(of(customer)), + update: vi.fn().mockReturnValue(of(customer)), + archive: vi.fn().mockReturnValue(of(customer)), + restore: vi.fn().mockReturnValue(of(customer)), + }; + TestBed.configureTestingModule({ + providers: [CustomerStore, { provide: CustomerService, useValue: service }], + }); + store = TestBed.inject(CustomerStore); + }); + it('cancels obsolete search pages and computes the server page count', () => { + const old = new Subject>(); + service.list.mockReturnValueOnce(old); + store.load({ organizationId: 'org', search: 'old' }); + store.load({ organizationId: 'org', search: 'hospital', page: 2 }); + expect(old.observed).toBe(false); + expect(store.customerEntities()).toEqual([customer]); + expect(store.pageCount()).toBe(2); + }); + it('retains same-organization records on failure but clears them when switching organization', () => { + store.load({ organizationId: 'org' }); + service.list.mockReturnValue( + throwError(() => ({ status: 503, title: 'Unavailable', detail: 'Try again' })), + ); + store.load({ organizationId: 'org', page: 2 }); + expect(store.customerEntities()).toEqual([customer]); + expect(store.listCallState().status).toBe('error'); + store.load({ organizationId: 'other' }); + expect(store.customerEntities()).toEqual([]); + expect(store.total()).toBe(0); + }); + it('does not cancel an accepted write on a repeated click', () => { + const accepted = new Subject(); + service.create.mockReturnValue(accepted); + store.load({ organizationId: 'org' }); + store.save({ kind: 'create', organizationId: 'org', input: { name: 'Hospital' } }); + store.save({ kind: 'create', organizationId: 'org', input: { name: 'Second' } }); + expect(accepted.observed).toBe(true); + expect(service.create).toHaveBeenCalledTimes(1); + accepted.next(customer); + accepted.complete(); + expect(store.writeCallState().status).toBe('success'); + }); + it('does not attribute a previous organization write failure to the new scope', () => { + const accepted = new Subject(); + service.create.mockReturnValue(accepted); + store.load({ organizationId: 'org' }); + store.save({ kind: 'create', organizationId: 'org', input: { name: 'Hospital' } }); + store.load({ organizationId: 'other' }); + expect(store.writeCallState().status).toBe('pending'); + accepted.error({ status: 412, title: 'Conflict' }); + expect(store.writeCallState().status).toBe('idle'); + }); + it('hydrates archived selected customers independently and cancels stale selections', () => { + const old = new Subject(); + service.get + .mockReturnValueOnce(old) + .mockReturnValueOnce(of({ ...customer, archivedAt: '2026-10-06T11:00:00Z' })); + store.read({ organizationId: 'org', customerId: 'old' }); + store.read({ organizationId: 'org', customerId: 'customer' }); + expect(old.observed).toBe(false); + expect(store.readCallState().data?.archivedAt).toBeTruthy(); + store.read(null); + expect(store.readCallState().data).toBeNull(); + }); +}); diff --git a/src/app/features/organization/features/customers/state/index.ts b/src/app/features/organization/features/customers/state/index.ts new file mode 100644 index 000000000..1f6287b87 --- /dev/null +++ b/src/app/features/organization/features/customers/state/index.ts @@ -0,0 +1,2 @@ +export { CustomerStore, customerStoreEvents } from './customer'; +export type { CustomerStoreType, CustomerQuery, CustomerCommand } from './customer'; diff --git a/src/app/features/organization/features/customers/ui/components/customer-label/customer-label.component.html b/src/app/features/organization/features/customers/ui/components/customer-label/customer-label.component.html new file mode 100644 index 000000000..c2faa0a4f --- /dev/null +++ b/src/app/features/organization/features/customers/ui/components/customer-label/customer-label.component.html @@ -0,0 +1,28 @@ +@if (!customerId()) { + No customer +} @else if (store.readCallState().data; as customer) { + {{ customer.name }} + @if (customer.archivedAt) { + (archived) + } +} @else if (store.readCallState().error; as error) { + {{ error.message }} +} @else { + +} diff --git a/src/app/features/organization/features/customers/ui/components/customer-label/customer-label.component.ts b/src/app/features/organization/features/customers/ui/components/customer-label/customer-label.component.ts new file mode 100644 index 000000000..301063595 --- /dev/null +++ b/src/app/features/organization/features/customers/ui/components/customer-label/customer-label.component.ts @@ -0,0 +1,132 @@ +import { isPlatformBrowser } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + PLATFORM_ID, + effect, + inject, + input, + untracked, + type InputSignal, +} from '@angular/core'; +import { + CustomerStore, + type CustomerStoreType, +} from '@features/organization/features/customers/state'; +import { HlmButton } from '@shared/ui/button'; +import { HlmSkeleton } from '@shared/ui/skeleton'; + +/** + * Class CustomerLabel + * @class CustomerLabel + * + * @description + * Reads an existing customer link without fetching an unrelated directory. + * + * @since unreleased + */ +@Component({ + selector: 'app-customer-label', + templateUrl: './customer-label.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [HlmButton, HlmSkeleton], + providers: [CustomerStore], +}) +export class CustomerLabel { + //#region Properties + /** + * Property customerId + * @readonly + * + * @description + * Existing customer id or an unassigned site. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly customerId: InputSignal = input(null); + /** + * Property organizationId + * @readonly + * + * @description + * Organization authority. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly organizationId: InputSignal = input.required(); + /** + * Property store + * @readonly + * + * @description + * Selected-record lifecycle. + * + * @access protected + * @since unreleased + * + * @type {CustomerStoreType} + */ + protected readonly store: CustomerStoreType = inject(CustomerStore); + /** + * Property platformId + * @readonly + * + * @description + * No authenticated secondary read on the server. + * + * @access private + * @since unreleased + * + * @type {object} + */ + private readonly platformId: object = inject(PLATFORM_ID); + //#endregion + + //#region Constructor + /** + * Constructor + * @constructor + * + * @description + * Cancels obsolete retained-link reads. + * + * @access public + * @since unreleased + */ + public constructor() { + effect(() => { + const organizationId = this.organizationId(); + const customerId = this.customerId(); + untracked(() => { + if (isPlatformBrowser(this.platformId)) + this.store.read(customerId ? { organizationId, customerId } : null); + }); + }); + } + //#endregion + + //#region Methods + /** + * Method retry + * @method + * + * @description + * Retries a failed retained-label read. + * + * @access protected + * @since unreleased + * + * @returns {void} No return value. + */ + protected retry(): void { + const customerId = this.customerId(); + if (customerId) this.store.read({ organizationId: this.organizationId(), customerId }); + } + //#endregion +} diff --git a/src/app/features/organization/features/customers/ui/components/customer-picker/customer-picker.component.html b/src/app/features/organization/features/customers/ui/components/customer-picker/customer-picker.component.html new file mode 100644 index 000000000..024025184 --- /dev/null +++ b/src/app/features/organization/features/customers/ui/components/customer-picker/customer-picker.component.html @@ -0,0 +1,79 @@ + + + +
+ No customer + @for (customer of store.customerEntities(); track customer.id) { + {{ customer.name }} + @if (customer.code) { + {{ customer.code }} + } + + } + @if (store.listCallState().status === 'success' && store.customerEntities().length === 0) { + No matching customers + } +
+
+ + {{ store.query()?.page ?? 1 }} / {{ store.pageCount() }} + +
+
+
+@if (store.listCallState().error; as error) { + + +} +@if (store.readCallState().error; as error) { + +} diff --git a/src/app/features/organization/features/customers/ui/components/customer-picker/customer-picker.component.ts b/src/app/features/organization/features/customers/ui/components/customer-picker/customer-picker.component.ts new file mode 100644 index 000000000..be5b66fbf --- /dev/null +++ b/src/app/features/organization/features/customers/ui/components/customer-picker/customer-picker.component.ts @@ -0,0 +1,274 @@ +import { isPlatformBrowser } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + PLATFORM_ID, + computed, + effect, + inject, + input, + model, + untracked, + type InputSignal, + type ModelSignal, + type Signal, +} from '@angular/core'; +import type { FormValueControl as FormValueControlType } from '@angular/forms/signals'; +import { + CustomerStore, + type CustomerStoreType, +} from '@features/organization/features/customers/state'; +import { HlmButton } from '@shared/ui/button'; +import { HlmComboboxImports } from '@shared/ui/combobox'; + +/** + * Class CustomerPicker + * @class CustomerPicker + * + * @description + * Browser-only customer choice widget keeps selected archived records readable. + * + * @since unreleased + */ +@Component({ + selector: 'app-customer-picker', + templateUrl: './customer-picker.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [HlmButton, ...HlmComboboxImports], + providers: [CustomerStore], +}) +export class CustomerPicker implements FormValueControlType { + //#region Properties + /** + * Property organizationId + * @readonly + * + * @description + * Organization authority for directory and retained-selection reads. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly organizationId: InputSignal = input(''); + /** + * Property value + * @readonly + * + * @description + * Signal Forms value; empty string means no customer. + * + * @access public + * @since unreleased + * + * @type {ModelSignal} + */ + public readonly value: ModelSignal = model(''); + /** + * Property inputId + * @readonly + * + * @description + * Native label association. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly inputId: InputSignal = input('customer-picker'); + /** + * Property disabled + * @readonly + * + * @description + * Disables selection without erasing its current value. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly disabled: InputSignal = input(false); + /** + * Property store + * @readonly + * + * @description + * Scoped directory and retained item request states. + * + * @access protected + * @since unreleased + * + * @type {CustomerStoreType} + */ + protected readonly store: CustomerStoreType = inject(CustomerStore); + /** + * Property platformId + * @readonly + * + * @description + * Browser demand-only loading avoids SSR side effects. + * + * @access private + * @since unreleased + * + * @type {object} + */ + private readonly platformId: object = inject(PLATFORM_ID); + /** + * Property placeholder + * @readonly + * + * @description + * Accessible choice placeholder. + * + * @access protected + * @since unreleased + * + * @type {string} + */ + protected readonly placeholder: string = $localize`:@@customer.picker.placeholder:Choose a customer`; + /** + * Property selectedLabel + * @readonly + * + * @description + * Retained selected record, even when absent from the active search page. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly selectedLabel: Signal = computed(() => { + const selected = this.store.readCallState().data; + return selected?.id === this.value() + ? selected.name + + (selected.archivedAt ? $localize`:@@customer.picker.archived: (archived)` : '') + : this.value(); + }); + /** + * Property labelOf + * @readonly + * + * @description + * Label callback uses selected-record hydration before directory fallback. + * + * @access protected + * @since unreleased + * + * @type {(value: unknown) => string} + */ + protected readonly labelOf: (value: unknown) => string = (value) => + typeof value !== 'string' || !value + ? $localize`:@@customer.picker.none:No customer` + : value === this.value() + ? this.selectedLabel() + : (this.store.customerEntities().find((customer) => customer.id === value)?.name ?? value); + /** + * Property remoteFilter + * @readonly + * + * @description + * Server search owns filtering; local filtering could hide later-page matches. + * + * @access protected + * @since unreleased + * + * @type {() => boolean} + */ + protected readonly remoteFilter: () => boolean = () => true; + //#endregion + + //#region Constructor + /** + * Constructor + * @constructor + * + * @description + * Hydrates existing links separately and cancels obsolete organization reads. + * + * @access public + * @since unreleased + */ + public constructor() { + effect(() => { + const organizationId = this.organizationId(); + const value = this.value(); + untracked(() => { + if (!isPlatformBrowser(this.platformId)) return; + if (this.store.query()?.organizationId !== organizationId) { + this.store.load(null); + if (organizationId) this.store.load({ organizationId }); + } + this.store.read(organizationId && value ? { organizationId, customerId: value } : null); + }); + }); + } + //#endregion + + //#region Methods + /** + * Method search + * @method + * + * @description + * Searches every server page instead of truncating the authorized directory. + * + * @access protected + * @since unreleased + * + * @param {string} search - Server search term across the authorized customer directory. + * + * @returns {void} No return value. + */ + protected search(search: string): void { + if (isPlatformBrowser(this.platformId) && this.organizationId()) + this.store.load({ organizationId: this.organizationId(), search, page: 1 }); + } + /** + * Method page + * @method + * + * @description + * Loads the selected candidate page without losing its search. + * + * @access protected + * @since unreleased + * + * @param {number} page - Requested server page, starting at one. + * + * @returns {void} No return value. + */ + protected page(page: number): void { + const query = this.store.query(); + if (query) this.store.load({ ...query, page }); + } + /** + * Method pick + * @method + * + * @description + * Accepts only active returned candidates or an explicit empty selection. + * + * @access protected + * @since unreleased + * + * @param {unknown} value - Value supplied by the owning customer workflow. + * + * @returns {void} No return value. + */ + protected pick(value: unknown): void { + if (this.disabled() || typeof value !== 'string') return; + if ( + !value || + this.store + .customerEntities() + .some((customer) => customer.id === value && !customer.archivedAt) + ) + this.value.set(value); + } + //#endregion +} diff --git a/src/app/features/organization/features/customers/ui/components/customer-picker/testing/customer-picker.component.spec.ts b/src/app/features/organization/features/customers/ui/components/customer-picker/testing/customer-picker.component.spec.ts new file mode 100644 index 000000000..bee247537 --- /dev/null +++ b/src/app/features/organization/features/customers/ui/components/customer-picker/testing/customer-picker.component.spec.ts @@ -0,0 +1,67 @@ +import { PLATFORM_ID } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { of } from 'rxjs'; +import { CustomerService } from '@features/organization/features/customers/data-access'; +import type { CustomerOutput } from '@features/organization/features/customers/models'; +import { CustomerPicker } from '../customer-picker.component'; + +describe('CustomerPicker', () => { + beforeAll(() => { + globalThis.ResizeObserver ??= class { + observe(): void {} + unobserve(): void {} + disconnect(): void {} + } as unknown as typeof ResizeObserver; + }); + const archived: CustomerOutput = { + '@id': 'customer', + '@type': 'Customer', + id: 'customer', + organizationId: 'org', + name: 'Hospital', + contacts: [], + revision: 1, + createdAt: '2026-10-06T10:00:00Z', + updatedAt: '2026-10-06T10:00:00Z', + archivedAt: '2026-10-06T11:00:00Z', + }; + it('does not read secondary authenticated data on the server', async () => { + const service = { list: vi.fn(), get: vi.fn() }; + TestBed.configureTestingModule({ + imports: [CustomerPicker], + providers: [ + { provide: PLATFORM_ID, useValue: 'server' }, + { provide: CustomerService, useValue: service }, + ], + }); + const fixture = TestBed.createComponent(CustomerPicker); + fixture.componentRef.setInput('organizationId', 'org'); + fixture.componentRef.setInput('value', 'customer'); + await fixture.whenStable(); + expect(service.list).not.toHaveBeenCalled(); + expect(service.get).not.toHaveBeenCalled(); + }); + it('retains an archived label outside pages but refuses archived new assignments', async () => { + const service = { + list: vi.fn().mockReturnValue(of({ member: [], totalItems: 0 })), + get: vi.fn().mockReturnValue(of(archived)), + }; + TestBed.configureTestingModule({ + imports: [CustomerPicker], + providers: [{ provide: CustomerService, useValue: service }], + }); + const fixture = TestBed.createComponent(CustomerPicker); + fixture.componentRef.setInput('organizationId', 'org'); + fixture.componentRef.setInput('value', 'customer'); + await fixture.whenStable(); + const picker = fixture.componentInstance as unknown as { + selectedLabel: () => string; + pick: (value: unknown) => void; + }; + expect(picker.selectedLabel()).toBe('Hospital (archived)'); + picker.pick('other-archived'); + expect(fixture.componentInstance.value()).toBe('customer'); + picker.pick(''); + expect(fixture.componentInstance.value()).toBe(''); + }); +}); diff --git a/src/app/features/organization/features/customers/ui/components/index.ts b/src/app/features/organization/features/customers/ui/components/index.ts new file mode 100644 index 000000000..ae46df7fc --- /dev/null +++ b/src/app/features/organization/features/customers/ui/components/index.ts @@ -0,0 +1,2 @@ +export { CustomerPicker } from './customer-picker/customer-picker.component'; +export { CustomerLabel } from './customer-label/customer-label.component'; diff --git a/src/app/features/organization/features/customers/ui/forms/customer-form/customer-form.component.html b/src/app/features/organization/features/customers/ui/forms/customer-form/customer-form.component.html new file mode 100644 index 000000000..0a2a6cb3f --- /dev/null +++ b/src/app/features/organization/features/customers/ui/forms/customer-form/customer-form.component.html @@ -0,0 +1,187 @@ +
+
+ Customer information +
+
+ + + @if (customerForm.name().touched()) { + @for (error of customerForm.name().errors(); track error) { + {{ error.message }} + } + } +
+
+ + + @if (customerForm.code().touched()) { + @for (error of customerForm.code().errors(); track error) { + {{ error.message }} + } + } +
+
+ + + @if (customerForm.email().touched()) { + @for (error of customerForm.email().errors(); track error) { + {{ error.message }} + } + } +
+
+ + + @if (customerForm.phone().touched()) { + @for (error of customerForm.phone().errors(); track error) { + {{ error.message }} + } + } +
+
+
+
+ Contacts +

+ Contacts are internal records. They do not receive access to FireGuard. +

+ @for (contact of draft().contacts; track $index; let index = $index) { +
+ Contact +
+
+ + + @if (customerForm.contacts[index].name().touched()) { + @for (error of customerForm.contacts[index].name().errors(); track error) { + {{ error.message }} + } + } +
+
+ + + @if (customerForm.contacts[index].role().touched()) { + @for (error of customerForm.contacts[index].role().errors(); track error) { + {{ error.message }} + } + } +
+
+ + + @if (customerForm.contacts[index].email().touched()) { + @for (error of customerForm.contacts[index].email().errors(); track error) { + {{ error.message }} + } + } +
+
+ + + @if (customerForm.contacts[index].phone().touched()) { + @for (error of customerForm.contacts[index].phone().errors(); track error) { + {{ error.message }} + } + } +
+ +
+
+ } + @for (error of customerForm.contacts().errors(); track error) { + {{ error.message }} + } + +
+ @if (error(); as message) { +
+

Customer could not be saved

+

{{ message }}

+
+ } +
+ + +
+
diff --git a/src/app/features/organization/features/customers/ui/forms/customer-form/customer-form.component.ts b/src/app/features/organization/features/customers/ui/forms/customer-form/customer-form.component.ts new file mode 100644 index 000000000..061e78b5c --- /dev/null +++ b/src/app/features/organization/features/customers/ui/forms/customer-form/customer-form.component.ts @@ -0,0 +1,534 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + input, + output, + signal, + untracked, + type InputSignal, + type OutputEmitterRef, + type Signal, + type WritableSignal, +} from '@angular/core'; +import { + applyEach, + disabled, + email, + form, + FormField, + maxLength, + required, + validate, + type FieldTree, +} from '@angular/forms/signals'; +import type { + CustomerInput, + CustomerOutput, +} from '@features/organization/features/customers/models'; +import { HlmAlertImports } from '@shared/ui/alert'; +import { HlmButton } from '@shared/ui/button'; +import { HlmFieldImports } from '@shared/ui/field'; +import { HlmInput } from '@shared/ui/input'; +import { HlmSpinner } from '@shared/ui/spinner'; + +/** + * Interface ContactDraft + * @interface + * + * @description + * Editable contact fields remain strings until a validated submission. + * + * @since unreleased + */ +interface ContactDraft { + /** + * Property name + * + * @description + * Display name; whitespace-only input is rejected before submission. + * + * @access public + * @since unreleased + * + * @type {string} + */ + name: string; + /** + * Property email + * + * @description + * Optional email address validated before submission. + * + * @access public + * @since unreleased + * + * @type {string} + */ + email: string; + /** + * Property phone + * + * @description + * Optional telephone number; no assumed country format. + * + * @access public + * @since unreleased + * + * @type {string} + */ + phone: string; + /** + * Property role + * + * @description + * Optional responsibility of the internal customer contact. + * + * @access public + * @since unreleased + * + * @type {string} + */ + role: string; +} +/** + * Interface CustomerDraft + * @interface + * + * @description + * Editable customer fields and contacts owned exclusively by the form. + * + * @since unreleased + */ +interface CustomerDraft { + /** + * Property name + * + * @description + * Display name; whitespace-only input is rejected before submission. + * + * @access public + * @since unreleased + * + * @type {string} + */ + name: string; + /** + * Property code + * + * @description + * Optional internal customer reference. + * + * @access public + * @since unreleased + * + * @type {string} + */ + code: string; + /** + * Property email + * + * @description + * Optional email address validated before submission. + * + * @access public + * @since unreleased + * + * @type {string} + */ + email: string; + /** + * Property phone + * + * @description + * Optional telephone number; no assumed country format. + * + * @access public + * @since unreleased + * + * @type {string} + */ + phone: string; + /** + * Property contacts + * + * @description + * Internal contacts retained with the customer, without external account access. + * + * @access public + * @since unreleased + * + * @type {ContactDraft[]} + */ + contacts: ContactDraft[]; +} +/** + * Function emptyDraft + * + * @description + * Creates an empty internal customer draft. + * + * @access public + * @since unreleased + * + * @returns {CustomerDraft} Result for the owning customer workflow. + */ +function emptyDraft(): CustomerDraft { + return { name: '', code: '', email: '', phone: '', contacts: [] }; +} +/** + * Function optional + * + * @description + * Maps optional blank fields to explicit clears accepted by merge patch. + * + * @access public + * @since unreleased + * + * @param {string} value - Value supplied by the owning customer workflow. + * + * @returns {string | null} Result for the owning customer workflow. + */ +function optional(value: string): string | null { + return value.trim() || null; +} + +/** + * Class CustomerForm + * @class CustomerForm + * + * @description + * Native customer form preserving failed drafts and emitting only validated transport input. + * + * @since unreleased + */ +@Component({ + selector: 'app-customer-form', + templateUrl: './customer-form.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [FormField, HlmButton, HlmInput, HlmSpinner, ...HlmFieldImports, ...HlmAlertImports], +}) +export class CustomerForm { + //#region Properties + /** + * Property customer + * @readonly + * + * @description + * Existing customer or an empty creation draft. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly customer: InputSignal = input(null); + /** + * Property pending + * @readonly + * + * @description + * Locks all editors during an accepted write. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly pending: InputSignal = input(false); + /** + * Property error + * @readonly + * + * @description + * Normalized server rejection, displayed without discarding input. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly error: InputSignal = input(null); + /** + * Property submitted + * @readonly + * + * @description + * Validated customer payload. + * + * @access public + * @since unreleased + * + * @type {OutputEmitterRef} + */ + public readonly submitted: OutputEmitterRef = output(); + /** + * Property cancelled + * @readonly + * + * @description + * Requests dismissal; the hosting sheet owns confirmation. + * + * @access public + * @since unreleased + * + * @type {OutputEmitterRef} + */ + public readonly cancelled: OutputEmitterRef = output(); + /** + * Property dirtyChanged + * @readonly + * + * @description + * Actual Signal Forms dirty state used by the host. + * + * @access public + * @since unreleased + * + * @type {OutputEmitterRef} + */ + public readonly dirtyChanged: OutputEmitterRef = output(); + /** + * Property draft + * @readonly + * + * @description + * Customer and contact drafts. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal} + */ + protected readonly draft: WritableSignal = signal(emptyDraft()); + /** + * Property customerForm + * @readonly + * + * @description + * Native field validation and submission state. + * + * @access protected + * @since unreleased + * + * @type {FieldTree} + */ + protected readonly customerForm: FieldTree = form(this.draft, (path) => { + disabled(path, () => this.pending()); + validate(path.contacts, ({ value }) => + value().length <= 50 + ? null + : { + kind: 'maxLength', + message: $localize`:@@customer.form.contactsLimit:Keep at most 50 contacts per customer.`, + }, + ); + required(path.name, { + message: $localize`:@@customer.form.nameRequired:Enter the customer name.`, + }); + validate(path.name, ({ value }) => + value().trim() + ? null + : { + kind: 'required', + message: $localize`:@@customer.form.nameRequired:Enter the customer name.`, + }, + ); + maxLength(path.name, 160, { + message: $localize`:@@customer.form.max160:Use at most 160 characters.`, + }); + maxLength(path.code, 80, { + message: $localize`:@@customer.form.max80:Use at most 80 characters.`, + }); + maxLength(path.email, 254, { + message: $localize`:@@customer.form.emailInvalid:Enter a valid email address.`, + }); + email(path.email, { + message: $localize`:@@customer.form.emailInvalid:Enter a valid email address.`, + }); + maxLength(path.phone, 40, { + message: $localize`:@@customer.form.max40:Use at most 40 characters.`, + }); + applyEach(path.contacts, (contact) => { + required(contact.name, { + message: $localize`:@@customer.form.contactRequired:Enter the contact name.`, + }); + validate(contact.name, ({ value }) => + value().trim() + ? null + : { + kind: 'required', + message: $localize`:@@customer.form.contactRequired:Enter the contact name.`, + }, + ); + maxLength(contact.name, 160, { + message: $localize`:@@customer.form.max160:Use at most 160 characters.`, + }); + maxLength(contact.email, 254, { + message: $localize`:@@customer.form.emailInvalid:Enter a valid email address.`, + }); + email(contact.email, { + message: $localize`:@@customer.form.emailInvalid:Enter a valid email address.`, + }); + maxLength(contact.phone, 40, { + message: $localize`:@@customer.form.max40:Use at most 40 characters.`, + }); + maxLength(contact.role, 80, { + message: $localize`:@@customer.form.max80:Use at most 80 characters.`, + }); + }); + }); + /** + * Property submitLabel + * @readonly + * + * @description + * Creation and edition share one truthful submit caption. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly submitLabel: Signal = computed(() => + this.customer() + ? $localize`:@@customer.form.save:Save customer` + : $localize`:@@customer.form.create:Create customer`, + ); + /** + * Property seededIdentity + * + * @description + * Identity already seeded; revision refreshes must not replace the entered draft. + * + * @access private + * @since unreleased + * + * @type {string | null | undefined} + */ + private seededIdentity: string | null | undefined = undefined; + //#endregion + + //#region Constructor + /** + * Constructor + * @constructor + * + * @description + * Seeds only when record identity changes; server rejection never resets the draft. + * + * @access public + * @since unreleased + */ + public constructor() { + effect(() => { + const customer = this.customer(); + const identity = customer?.id ?? null; + if (this.seededIdentity === identity) return; + this.seededIdentity = identity; + untracked(() => { + this.customerForm().reset( + customer + ? { + name: customer.name, + code: customer.code ?? '', + email: customer.email ?? '', + phone: customer.phone ?? '', + contacts: customer.contacts.map((contact) => ({ + name: contact.name, + email: contact.email ?? '', + phone: contact.phone ?? '', + role: contact.role ?? '', + })), + } + : emptyDraft(), + ); + }); + }); + effect(() => { + const dirty = this.customerForm().dirty(); + untracked(() => this.dirtyChanged.emit(dirty)); + }); + } + //#endregion + + //#region Methods + /** + * Method addContact + * @method + * + * @description + * Adds a contact without changing the server directory. + * + * @access protected + * @since unreleased + * + * @returns {void} No return value. + */ + protected addContact(): void { + if (!this.pending() && this.draft().contacts.length < 50) { + this.draft.update((draft) => ({ + ...draft, + contacts: [...draft.contacts, { name: '', email: '', phone: '', role: '' }], + })); + this.customerForm().markAsDirty(); + } + } + /** + * Method removeContact + * @method + * + * @description + * Removes one local contact while keeping the remaining draft. + * + * @access protected + * @since unreleased + * + * @param {number} index - Value supplied by the owning customer workflow. + * + * @returns {void} No return value. + */ + protected removeContact(index: number): void { + if (!this.pending()) { + this.draft.update((draft) => ({ + ...draft, + contacts: draft.contacts.filter((_, position) => position !== index), + })); + this.customerForm().markAsDirty(); + } + } + /** + * Method submit + * @method + * + * @description + * Exposes errors before emitting normalized customer and contact data. + * + * @access protected + * @since unreleased + * + * @param {Event} event - Value supplied by the owning customer workflow. + * + * @returns {void} No return value. + */ + protected submit(event: Event): void { + event.preventDefault(); + this.customerForm().markAsTouched(); + if (this.pending() || this.customerForm().invalid()) return; + const draft = this.draft(); + this.submitted.emit({ + name: draft.name.trim(), + code: optional(draft.code), + email: optional(draft.email), + phone: optional(draft.phone), + contacts: draft.contacts.map((contact) => ({ + name: contact.name.trim(), + email: optional(contact.email), + phone: optional(contact.phone), + role: optional(contact.role), + })), + }); + } + //#endregion +} diff --git a/src/app/features/organization/features/customers/ui/forms/customer-form/testing/customer-form.component.spec.ts b/src/app/features/organization/features/customers/ui/forms/customer-form/testing/customer-form.component.spec.ts new file mode 100644 index 000000000..d62db16ac --- /dev/null +++ b/src/app/features/organization/features/customers/ui/forms/customer-form/testing/customer-form.component.spec.ts @@ -0,0 +1,104 @@ +import { TestBed, type ComponentFixture } from '@angular/core/testing'; +import type { CustomerInput } from '@features/organization/features/customers/models'; +import { CustomerForm } from '../customer-form.component'; + +describe('CustomerForm', () => { + let fixture: ComponentFixture; + beforeEach(async () => { + TestBed.configureTestingModule({ imports: [CustomerForm] }); + fixture = TestBed.createComponent(CustomerForm); + await fixture.whenStable(); + }); + const fill = async (selector: string, value: string): Promise => { + const input = fixture.nativeElement.querySelector(selector) as HTMLInputElement; + input.value = value; + input.dispatchEvent(new Event('input')); + await fixture.whenStable(); + }; + const submit = async (): Promise => { + (fixture.nativeElement.querySelector('form') as HTMLFormElement).dispatchEvent( + new Event('submit', { cancelable: true }), + ); + await fixture.whenStable(); + }; + it('rejects a blank customer and malformed email then emits trimmed optional clears', async () => { + const emitted: CustomerInput[] = []; + fixture.componentInstance.submitted.subscribe((input) => emitted.push(input)); + await submit(); + expect(emitted).toHaveLength(0); + await fill('#customer-name', ' Hospital '); + await fill('#customer-email', 'invalid'); + await submit(); + expect(emitted).toHaveLength(0); + await fill('#customer-email', ''); + await submit(); + expect(emitted).toEqual([ + { name: 'Hospital', code: null, email: null, phone: null, contacts: [] }, + ]); + }); + it('requires contact identity and preserves entered customer data after server rejection', async () => { + const emitted: CustomerInput[] = []; + fixture.componentInstance.submitted.subscribe((input) => emitted.push(input)); + await fill('#customer-name', 'Hospital'); + const add = Array.from( + fixture.nativeElement.querySelectorAll('button') as NodeListOf, + ).find((button) => button.textContent?.includes('Add contact')); + if (!add) throw new Error('Missing contact creation action'); + add.click(); + await fixture.whenStable(); + await submit(); + expect(emitted).toHaveLength(0); + await fill('#contact-name-0', ' Safety manager '); + await fill('#contact-role-0', ' Safety '); + await submit(); + expect(emitted[0].contacts?.[0]).toEqual({ + name: 'Safety manager', + email: null, + phone: null, + role: 'Safety', + }); + fixture.componentRef.setInput('error', 'Revision conflict'); + await fixture.whenStable(); + expect((fixture.nativeElement.querySelector('#customer-name') as HTMLInputElement).value).toBe( + 'Hospital', + ); + expect(fixture.nativeElement.querySelector('[role="alert"]')?.textContent).toContain( + 'Revision conflict', + ); + }); + + it('refreshes the displayed revision without resetting an edited draft', async () => { + const customer = { + '@id': 'customer', + '@type': 'Customer', + id: 'customer', + organizationId: 'org', + name: 'Original', + contacts: [], + revision: 1, + createdAt: '2026-10-06T10:00:00Z', + updatedAt: '2026-10-06T10:00:00Z', + }; + fixture.componentRef.setInput('customer', customer); + await fixture.whenStable(); + await fill('#customer-name', 'My draft'); + fixture.componentRef.setInput('customer', { + ...customer, + name: 'Concurrent change', + revision: 2, + }); + await fixture.whenStable(); + expect((fixture.nativeElement.querySelector('#customer-name') as HTMLInputElement).value).toBe( + 'My draft', + ); + }); + + it('shows the server-aligned length limit instead of silently refusing submission', async () => { + const emitted: CustomerInput[] = []; + fixture.componentInstance.submitted.subscribe((input) => emitted.push(input)); + await fill('#customer-name', 'A'.repeat(161)); + await submit(); + expect(emitted).toHaveLength(0); + expect(fixture.nativeElement.textContent).toContain('Use at most 160 characters.'); + }); +}); diff --git a/src/app/features/organization/features/customers/ui/pages/customers-page/customers-page.component.html b/src/app/features/organization/features/customers/ui/pages/customers-page/customers-page.component.html new file mode 100644 index 000000000..e19a46e17 --- /dev/null +++ b/src/app/features/organization/features/customers/ui/pages/customers-page/customers-page.component.html @@ -0,0 +1,207 @@ +
+
+
+

Customers

+

+ Internal customer records and contacts for your sites. +

+
+ @if (canManage()) { + + } +
+
+
+ @if (!online()) { +
+

Customers are managed online

+

+ Reconnect to update customer records. Your draft is preserved. +

+
+ } +
+
+ +
+ + + +
+ @if (store.listCallState().error; as error) { +
+

Customers could not be loaded

+

{{ error.message }}

+ +
+ } + @if (!editorVisible() && store.writeCallState().error; as error) { +
+

Customer action failed

+

{{ error.message }}

+
+ } + @if (store.listCallState().status === 'pending' && store.customerEntities().length === 0) { +
+ } + @if (store.customerEntities().length > 0) { +
+ + + + + + + + + + + @for (customer of store.customerEntities(); track customer.id) { + + + + + + + } + +
NameInternal referenceContactsActions
+ {{ customer.name }} + @if (customer.archivedAt) { + Archived + } +

+ {{ customer.email }} {{ customer.phone }} +

+
{{ customer.code }} +
    + @for (contact of customer.contacts; track $index) { +
  • + {{ contact.name }} + {{ contact.role }} {{ contact.email }} {{ contact.phone }} +
  • + } +
+
+ @if (canManage()) { +
+ +
+ } +
+
+ + } @else if (store.listCallState().status === 'success') { +
+ @if (search()) { + + } @else { + + } +
+

No customers in this view

+

+ Change your search or create a customer. Sites can also be managed without a customer. +

+ } +
+@if (editorVisible()) { + +} diff --git a/src/app/features/organization/features/customers/ui/pages/customers-page/customers-page.component.ts b/src/app/features/organization/features/customers/ui/pages/customers-page/customers-page.component.ts new file mode 100644 index 000000000..58679d53f --- /dev/null +++ b/src/app/features/organization/features/customers/ui/pages/customers-page/customers-page.component.ts @@ -0,0 +1,454 @@ +import { isPlatformBrowser } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + PLATFORM_ID, + computed, + effect, + inject, + input, + signal, + untracked, + type InputSignal, + type Signal, + type WritableSignal, +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { Events } from '@ngrx/signals/events'; +import { ConnectivityService } from '@core/connectivity'; +import { OrganizationPermissionService } from '@features/organization/access'; +import type { + CustomerInput, + CustomerOutput, +} from '@features/organization/features/customers/models'; +import { + CustomerStore, + customerStoreEvents, + type CustomerStoreType, +} from '@features/organization/features/customers/state'; +import { ORGANIZATION_PERMISSION } from '@features/organization/models'; +import { CollectionPagination } from '@shared/collection-pagination'; +import { ResourceIllustration } from '@shared/resource-illustration'; +import { StateIllustration } from '@shared/state-illustration'; +import { HlmAlertImports } from '@shared/ui/alert'; +import { HlmBadge } from '@shared/ui/badge'; +import { HlmButton } from '@shared/ui/button'; +import { HlmEmptyImports } from '@shared/ui/empty'; +import { HlmInput } from '@shared/ui/input'; +import { HlmSkeleton } from '@shared/ui/skeleton'; +import { HlmTableImports } from '@shared/ui/table'; +import { HlmToggleGroupImports } from '@shared/ui/toggle-group'; +import { CustomerEditorSheet } from '../../sheets/customer-editor-sheet/customer-editor-sheet.component'; + +/** + * Class CustomersPage + * @class CustomersPage + * + * @description + * Internal customer directory uses browser-only server pagination and explicit archive actions. + * + * @since unreleased + */ +@Component({ + selector: 'app-customers-page', + templateUrl: './customers-page.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [CustomerStore], + imports: [ + CustomerEditorSheet, + CollectionPagination, + ResourceIllustration, + StateIllustration, + HlmButton, + HlmBadge, + HlmInput, + HlmSkeleton, + ...HlmAlertImports, + ...HlmEmptyImports, + ...HlmTableImports, + ...HlmToggleGroupImports, + ], +}) +export class CustomersPage { + //#region Properties + /** + * Property organizationId + * @readonly + * + * @description + * Resolved organization context from the owning route. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly organizationId: InputSignal = input.required(); + /** + * Property store + * @readonly + * + * @description + * Page-local directory state. + * + * @access protected + * @since unreleased + * + * @type {CustomerStoreType} + */ + protected readonly store: CustomerStoreType = inject(CustomerStore); + /** + * Property connectivity + * @readonly + * + * @description + * Connectivity governs online directory administration without discarding drafts. + * + * @access private + * @since unreleased + * + * @type {ConnectivityService} + */ + private readonly connectivity: ConnectivityService = inject(ConnectivityService); + /** + * Property permissions + * @readonly + * + * @description + * Access authority inherited from the organization context. + * + * @access private + * @since unreleased + * + * @type {OrganizationPermissionService} + */ + private readonly permissions: OrganizationPermissionService = inject( + OrganizationPermissionService, + ); + /** + * Property platformId + * @readonly + * + * @description + * Server work is browser-only for this secondary collection. + * + * @access private + * @since unreleased + * + * @type {object} + */ + private readonly platformId: object = inject(PLATFORM_ID); + /** + * Property search + * @readonly + * + * @description + * Committed server search. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal} + */ + protected readonly search: WritableSignal = signal(''); + /** + * Property page + * @readonly + * + * @description + * Server page. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal} + */ + protected readonly page: WritableSignal = signal(1); + /** + * Property archived + * @readonly + * + * @description + * Active and archived records have separate server universes. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal} + */ + protected readonly archived: WritableSignal = signal(false); + /** + * Property editorVisible + * @readonly + * + * @description + * Editor visibility is independent of the selected record. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal} + */ + protected readonly editorVisible: WritableSignal = signal(false); + /** + * Property editing + * @readonly + * + * @description + * Immutable revision displayed when editing. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal} + */ + protected readonly editing: WritableSignal = signal( + null, + ); + /** + * Property canManage + * @readonly + * + * @description + * Customer management remains permission-gated. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly canManage: Signal = computed(() => + this.permissions.hasPermission(ORGANIZATION_PERMISSION.CUSTOMERS_MANAGE), + ); + /** + * Property canRead + * @readonly + * + * @description + * Reads remain independently granted. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly canRead: Signal = computed(() => + this.permissions.hasPermission(ORGANIZATION_PERMISSION.CUSTOMERS_READ), + ); + /** + * Property online + * @readonly + * + * @description + * Network status exposed distinctly from an empty list. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly online: Signal = computed(() => this.connectivity.isOnline()); + //#endregion + + //#region Constructor + /** + * Constructor + * @constructor + * + * @description + * Cancels old organization reads and handles successful writes once. + * + * @access public + * @since unreleased + */ + public constructor() { + effect(() => { + this.organizationId(); + untracked(() => { + this.page.set(1); + this.search.set(''); + this.archived.set(false); + this.editorVisible.set(false); + this.editing.set(null); + this.store.load(null); + }); + }); + effect(() => { + const query = { + organizationId: this.organizationId(), + page: this.page(), + search: this.search(), + archived: this.archived(), + }; + const enabled = this.canRead() && this.online() && isPlatformBrowser(this.platformId); + untracked(() => { + if (enabled) this.store.load(query); + }); + }); + effect(() => { + const latest = this.store.readCallState().data; + untracked(() => { + if (latest?.organizationId === this.organizationId() && latest.id === this.editing()?.id) { + this.editing.set(latest); + this.store.clearWrite(); + } + }); + }); + inject(Events) + .on(customerStoreEvents.saved) + .pipe(takeUntilDestroyed()) + .subscribe(({ payload }) => { + if (payload.organizationId !== this.organizationId()) return; + this.editorVisible.set(false); + this.editing.set(null); + this.reload(); + }); + } + //#endregion + + //#region Methods + /** + * Method refreshRevision + * @method + * + * @description + * Reloads the record revision after a conflict without replacing entered form values. + * + * @access protected + * @since unreleased + * + * @returns {void} No return value. + */ + protected refreshRevision(): void { + const customer = this.editing(); + if (customer && this.online()) + this.store.read({ organizationId: this.organizationId(), customerId: customer.id }); + } + + /** + * Method searchChanged + * @method + * + * @description + * Applies a new server search from page one. + * + * @access protected + * @since unreleased + * + * @param {string} value - Value supplied by the owning customer workflow. + * + * @returns {void} No return value. + */ + protected searchChanged(value: string): void { + this.search.set(value); + this.page.set(1); + } + /** + * Method archiveFilter + * @method + * + * @description + * Switches between active and archived customer directories. + * + * @access protected + * @since unreleased + * + * @param {unknown} value - Value supplied by the owning customer workflow. + * + * @returns {void} No return value. + */ + protected archiveFilter(value: unknown): void { + if (value === 'active' || value === 'archived') { + this.archived.set(value === 'archived'); + this.page.set(1); + } + } + /** + * Method open + * @method + * + * @description + * Opens a fresh editor without carrying a previous rejection. + * + * @access protected + * @since unreleased + * + * @param {CustomerOutput | null} customer - Value supplied by the owning customer workflow. + * + * @returns {void} No return value. + */ + protected open(customer: CustomerOutput | null): void { + if (!this.canManage() || !this.online()) return; + this.store.clearWrite(); + this.store.read(null); + this.editing.set(customer); + this.editorVisible.set(true); + } + /** + * Method submitted + * @method + * + * @description + * Saves against the displayed revision, keeping drafts until confirmation. + * + * @access protected + * @since unreleased + * + * @param {CustomerInput} payload - Value supplied by the owning customer workflow. + * + * @returns {void} No return value. + */ + protected submitted(payload: CustomerInput): void { + if (!this.canManage() || !this.online()) return; + const customer = this.editing(); + this.store.save( + customer + ? { kind: 'update', organizationId: this.organizationId(), customer, input: payload } + : { kind: 'create', organizationId: this.organizationId(), input: payload }, + ); + } + /** + * Method archive + * @method + * + * @description + * Archives or restores a revision-checked customer, preserving existing links. + * + * @access protected + * @since unreleased + * + * @param {CustomerOutput} customer - Value supplied by the owning customer workflow. + * + * @returns {void} No return value. + */ + protected archive(customer: CustomerOutput): void { + if (!this.canManage() || !this.online()) return; + this.store.save({ + kind: customer.archivedAt ? 'restore' : 'archive', + organizationId: this.organizationId(), + customer, + }); + } + /** + * Method reload + * @method + * + * @description + * Retries the same committed directory query. + * + * @access protected + * @since unreleased + * + * @returns {void} No return value. + */ + protected reload(): void { + if (this.canRead() && this.online() && isPlatformBrowser(this.platformId)) + this.store.load({ + organizationId: this.organizationId(), + page: this.page(), + search: this.search(), + archived: this.archived(), + }); + } + //#endregion +} diff --git a/src/app/features/organization/features/customers/ui/pages/customers-page/testing/customers-page.component.spec.ts b/src/app/features/organization/features/customers/ui/pages/customers-page/testing/customers-page.component.spec.ts new file mode 100644 index 000000000..0d67f0785 --- /dev/null +++ b/src/app/features/organization/features/customers/ui/pages/customers-page/testing/customers-page.component.spec.ts @@ -0,0 +1,65 @@ +import { PLATFORM_ID, signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { of } from 'rxjs'; +import { ConnectivityService } from '@core/connectivity'; +import { THEME_PORT } from '@core/theme'; +import { OrganizationPermissionService } from '@features/organization/access'; +import { CustomerService } from '@features/organization/features/customers/data-access'; +import { CustomersPage } from '../customers-page.component'; + +describe('CustomersPage', () => { + beforeAll(() => { + globalThis.ResizeObserver ??= class { + observe(): void {} + unobserve(): void {} + disconnect(): void {} + } as unknown as typeof ResizeObserver; + }); + it('loads active customer pages and switches to the archived server universe', async () => { + const service = { list: vi.fn().mockReturnValue(of({ member: [], totalItems: 0 })) }; + TestBed.configureTestingModule({ + imports: [CustomersPage], + providers: [ + { provide: CustomerService, useValue: service }, + { provide: THEME_PORT, useValue: { resolvedTheme: signal('light') } }, + { provide: OrganizationPermissionService, useValue: { hasPermission: () => true } }, + { provide: ConnectivityService, useValue: { isOnline: signal(true) } }, + ], + }); + const fixture = TestBed.createComponent(CustomersPage); + fixture.componentRef.setInput('organizationId', 'org'); + await fixture.whenStable(); + expect(service.list).toHaveBeenLastCalledWith( + 'org', + expect.objectContaining({ params: { archived: false } }), + ); + const page = fixture.componentInstance as unknown as { + archiveFilter: (value: unknown) => void; + }; + page.archiveFilter('archived'); + await fixture.whenStable(); + expect(service.list).toHaveBeenLastCalledWith( + 'org', + expect.objectContaining({ params: { archived: true } }), + ); + expect(fixture.nativeElement.textContent).toContain('No customers in this view'); + }); + it('does not read customer data during SSR or without read permission', async () => { + const service = { list: vi.fn() }; + TestBed.configureTestingModule({ + imports: [CustomersPage], + providers: [ + { provide: PLATFORM_ID, useValue: 'server' }, + { provide: CustomerService, useValue: service }, + { provide: THEME_PORT, useValue: { resolvedTheme: signal('light') } }, + { provide: OrganizationPermissionService, useValue: { hasPermission: () => false } }, + { provide: ConnectivityService, useValue: { isOnline: signal(true) } }, + ], + }); + const fixture = TestBed.createComponent(CustomersPage); + fixture.componentRef.setInput('organizationId', 'org'); + await fixture.whenStable(); + expect(service.list).not.toHaveBeenCalled(); + expect(fixture.nativeElement.textContent).not.toContain('New customer'); + }); +}); diff --git a/src/app/features/organization/features/customers/ui/sheets/customer-editor-sheet/customer-editor-sheet.component.html b/src/app/features/organization/features/customers/ui/sheets/customer-editor-sheet/customer-editor-sheet.component.html new file mode 100644 index 000000000..00b640a08 --- /dev/null +++ b/src/app/features/organization/features/customers/ui/sheets/customer-editor-sheet/customer-editor-sheet.component.html @@ -0,0 +1,44 @@ + + + +

{{ title() }}

+

+ Keep the customer's identity and contacts together for its sites. +

+
+ @if (conflict()) { + + } + +
+
+ diff --git a/src/app/features/organization/features/customers/ui/sheets/customer-editor-sheet/customer-editor-sheet.component.ts b/src/app/features/organization/features/customers/ui/sheets/customer-editor-sheet/customer-editor-sheet.component.ts new file mode 100644 index 000000000..f897c4f8b --- /dev/null +++ b/src/app/features/organization/features/customers/ui/sheets/customer-editor-sheet/customer-editor-sheet.component.ts @@ -0,0 +1,290 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + input, + output, + signal, + viewChild, + type InputSignal, + type OutputEmitterRef, + type Signal, + type WritableSignal, +} from '@angular/core'; +import type { BrnDialogState } from '@spartan-ng/brain/dialog'; +import type { + CustomerInput, + CustomerOutput, +} from '@features/organization/features/customers/models'; +import { sheetSide } from '@shared/sheet-side'; +import { HlmButton } from '@shared/ui/button'; +import { HlmSheet, HlmSheetImports } from '@shared/ui/sheet'; +import { UnsavedChangesDialog } from '@shared/unsaved-changes'; +import { CustomerForm } from '../../forms/customer-form/customer-form.component'; + +/** + * Class CustomerEditorSheet + * @class CustomerEditorSheet + * + * @description + * Customer editor retains failed drafts and confirms dismissal of unsaved edits. + * + * @since unreleased + */ +@Component({ + selector: 'app-customer-editor-sheet', + templateUrl: './customer-editor-sheet.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [HlmButton, CustomerForm, UnsavedChangesDialog, ...HlmSheetImports], +}) +export class CustomerEditorSheet { + //#region Properties + /** + * Property conflict + * @readonly + * + * @description + * Whether a stale revision can be explicitly refreshed without discarding edits. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly conflict: InputSignal = input(false); + /** + * Property refreshRequested + * @readonly + * + * @description + * Refreshes the latest server revision while preserving the local draft. + * + * @access public + * @since unreleased + * + * @type {OutputEmitterRef} + */ + public readonly refreshRequested: OutputEmitterRef = output(); + /** + * Property sheetRef + * @readonly + * + * @description + * Native sheet reopened when dismissal would abandon a dirty draft. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly sheetRef: Signal = viewChild(HlmSheet); + + /** + * Property visible + * @readonly + * + * @description + * Hosting page owns visibility. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly visible: InputSignal = input(false); + /** + * Property customer + * @readonly + * + * @description + * Existing record or creation. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly customer: InputSignal = input(null); + /** + * Property pending + * @readonly + * + * @description + * Accepted command locks dismissal and editing. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly pending: InputSignal = input(false); + /** + * Property error + * @readonly + * + * @description + * Server rejection without draft reset. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly error: InputSignal = input(null); + /** + * Property dismissed + * @readonly + * + * @description + * Confirmed editor dismissal. + * + * @access public + * @since unreleased + * + * @type {OutputEmitterRef} + */ + public readonly dismissed: OutputEmitterRef = output(); + /** + * Property submitted + * @readonly + * + * @description + * Validated input forwarded to the page. + * + * @access public + * @since unreleased + * + * @type {OutputEmitterRef} + */ + public readonly submitted: OutputEmitterRef = output(); + /** + * Property dirty + * @readonly + * + * @description + * Actual form dirty state. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal} + */ + protected readonly dirty: WritableSignal = signal(false); + /** + * Property confirmation + * @readonly + * + * @description + * Unsaved changes confirmation. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal} + */ + protected readonly confirmation: WritableSignal = + signal('closed'); + /** + * Property state + * @readonly + * + * @description + * Native sheet state follows the single owner. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly state: Signal = computed(() => + this.visible() ? 'open' : 'closed', + ); + /** + * Property side + * @readonly + * + * @description + * Responsive sheet placement. + * + * @access protected + * @since unreleased + * + * @type {Signal<'right' | 'bottom'>} + */ + protected readonly side: Signal<'right' | 'bottom'> = sheetSide(); + /** + * Property title + * @readonly + * + * @description + * Current workflow title. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly title: Signal = computed(() => + this.customer() + ? $localize`:@@customer.editor.edit:Edit customer` + : $localize`:@@customer.editor.create:New customer`, + ); + //#endregion + + //#region Methods + /** + * Method requestClose + * @method + * + * @description + * Preserves drafts when a native dismissal is requested. + * + * @access protected + * @since unreleased + * + * @returns {void} No return value. + */ + protected requestClose(): void { + if (this.pending()) return; + if (this.dirty()) this.confirmation.set('open'); + else this.dismissed.emit(); + } + /** + * Method stateChanged + * @method + * + * @description + * Relays native closure through the same dirty-state policy. + * + * @access protected + * @since unreleased + * + * @param {BrnDialogState} state - Value supplied by the owning customer workflow. + * + * @returns {void} No return value. + */ + protected stateChanged(state: BrnDialogState): void { + if (state === 'closed' && this.visible()) { + if (this.dirty()) this.sheetRef()?.open(); + this.requestClose(); + } + } + /** + * Method discard + * @method + * + * @description + * Discards only after the user's explicit dismissal choice. + * + * @access protected + * @since unreleased + * + * @returns {void} No return value. + */ + protected discard(): void { + this.confirmation.set('closed'); + this.dirty.set(false); + this.dismissed.emit(); + } + //#endregion +} diff --git a/src/app/features/organization/features/equipments/FEATURE.md b/src/app/features/organization/features/equipments/FEATURE.md index 1e5deaa18..6aef185e5 100644 --- a/src/app/features/organization/features/equipments/FEATURE.md +++ b/src/app/features/organization/features/equipments/FEATURE.md @@ -2,297 +2,142 @@ **Reading guide:** [Documentation index](../../../../../../docs/README.md) · [Related guide](../../../../../../docs/guides/facilities-and-spatial-views.md). -## Purpose - -Owns organization-scoped equipment workflows. - -`EquipmentService.summaryByFacility` reads the Equipment-owned exact summary: -`scope`, `totalItems`, all four `byStatus` counts and `needingAttentionCount`. -Detail and Assets share a default subtree scope and explicit direct option -through `equipmentScope`; both pass matching `includeDescendants` values. -Summary counts are independent of list page limits. Equipment read permission is -distinct from Facilities read permission. Creation plus assignment/intervention -association is atomic in the API, and conflict handling preserves input drafts. - -This subfeature is responsible for: - -- listing equipments for the active organization, -- equipment creation, detail, and editing, -- assignment, unassignment, commissioning, maintenance, and decommissioning actions, -- maintenance logs, attachments, and tags, -- active equipment selection and detail-oriented state. - -This subfeature does not own top-level organization context or inspection workflows. - -## Entry Points - -- Routes: `equipments.routes.ts` -- Public API: `index.ts`, narrowed to `EQUIPMENT_TYPE_OPTIONS` — the only symbol - any external consumer imports (see below). It used to `export *` the state, - models, data-access and options trees. - -## Routes - -- `/organizations/:organizationId/equipments` — `EquipmentsPage`: an - `hlmTable` of the organization's equipment (`EquipmentTable`), a debounced - search box (`?q=`, mapped to the backend `search` filter) and an editable - type/status filter chip row (`app-collection-filter-bar`, - `@shared/collection-filters`, replacing the earlier popover), paginated - server-side. No row menu and no bulk actions — the record itself is where - every property is edited (see below), so the list has nothing left to - orchestrate beyond search, filter, page and a "New equipment" link - (`EQUIPMENT_WRITE`-gated) into `create`. `EquipmentTable`'s Equipment - (type), Brand / model (brand) and Status heads are sortable — the - backend's own `order[]` whitelist (`type`, `status`, `brand`, - `model`, `createdAt`, `updatedAt`; `ListEquipmentsProvider`) intersected - with the columns this table renders; `model` and the two timestamps have - no dedicated column and carry no sortable head. `EquipmentsPage.sortOrder` - is sent through the typed `RequestOptions.sort` option (`@core/api`), and - remembered across visits by `EquipmentListPreferencesService` - (`fg-equipment-list` cookie) — the third feature-local occurrence of the - cookie-preference shape `InterventionListPreferencesService` introduced; - kept local rather than shared (`ARCHITECTURE.md` §2.9). -- The list toolbar's **Export** button downloads a server-side CSV - (`EquipmentService.exportCsv`, `GET -/api/organizations/{organizationId}/equipment/export`, mirroring - `InterventionService.exportCsv`: direct `this.http` call, `responseType: -'blob'`, saved through `BrowserDownloadService`). The endpoint accepts - **no filter by design** — the export always covers the whole inventory — - so any active search or filter raises the - `equipment.list.exportFiltersIgnored` warn toast before the download. The - server caps the collection at 50,000 rows; the resulting 422's RFC 7807 - `detail` (read back through `resolveCsvExportErrorDetail`, - `@features/organization/utils`) is surfaced as the error toast. -- The list toolbar's **Print QR labels** button downloads the printable QR - label sheet as PDF (`EquipmentService.exportLabels`, `GET -/api/organizations/{organizationId}/equipment/labels`, same - direct-`this.http` blob shape, saved as - `equipment-labels-{organizationId}.pdf`). Its action surface explicitly - selects inventory, one facility, or checked records from the current - list page. Inventory/site counts come from the server's filtered total; - selections count distinct identities. `EquipmentLabelsStore` enables printing - only after a matching preview resolves between 1 and 500 labels, exposes - independent preview/export errors, and cancels obsolete scope/session reads. - An empty explicit selection is rejected rather than expanding to inventory. - The facility-scoped variant of the same - endpoint lives on the estate explorer (`organization/FEATURE.md` - "Assets"), which prints the selected node's subtree via `facilityId`. -- The detail page's header carries **Export equipment sheet** - (`EquipmentService.exportReport`, `GET -/api/organizations/{organizationId}/equipment/{equipmentId}/report`): the - equipment's PDF sheet, same direct-`this.http` blob shape, saved as - `equipment-{equipmentId}-sheet.pdf`. The endpoint is additionally gated on - the organization's plan tier (pro/max): a non-entitled plan answers 403 - with an RFC 7807 `detail`, read back through `resolveCsvExportErrorDetail` - and surfaced verbatim as the error toast. -- `/organizations/:organizationId/equipments/create` — a functional redirect onto the list with `?create=1` (keeping `?facility=`); creation is `EquipmentCreateSheet`, opened by `EquipmentsPage`: - `EquipmentCreateForm` (Signal Forms) asking for the one required field, - `type`; the five remaining editable properties are filled in afterward, in - place, on the created record. Navigates to `/:equipmentId` on success. -- `/organizations/:organizationId/equipments/:equipmentId` — - `EquipmentDetailPage`. `equipmentResolver` (route `resolve`) **seeds** - `ActiveEquipmentStore` fire-and-forget so activation never waits on the - network (first-order on slow field connections): the page paints a - full-page skeleton from the store's pending state, and on load failure the - page — not the resolver — toasts through the global feedback listener and - returns to the index. `equipmentTitleResolver` (route `title`) answers - synchronously via `buildEquipmentTitle` (`utils/equipment-title/`), falling - back to a neutral section label; once the record lands the page re-sets the - document title through `TitleService`, which also refreshes the - breadcrumb's current-page label. The resolver stays the single loading path - for the record — the page never re-fetches it. - - The lifecycle status band (page header) names the single relevant forward - transition for the current status — commission, resume service, or move - to maintenance — as the primary action, with Decommission as the - secondary; both read `EquipmentOutput.status` only, no per-status template - branching. `EquipmentStore.commission` serves both "Commission" - (`in_stock`) and "Resume service" (`under_maintenance`): the backend - handler accepts either non-decommissioned status and always lands on - `operational`. - - **Decommission confirms; the forward transitions do not.** It is the one - terminal move — `primaryAction()` resolves to `null` on a `decommissioned` - record, so nothing puts the equipment back in service afterwards — which is - exactly the case `DESIGN.md` §Action Surfaces rule 5 reserves a confirmation - for. It opens `ui/dialogs/equipment-decommission-dialog/`, a feature-local - alert-dialog on the `organization-delete-dialog` model: presentational, it - emits `confirmed` and never calls the store. Until 2026-08-28 the action - fired on a single click from the shell header, with no way back. - -**Creation is site-scoped.** `EquipmentCreatePage` binds `?facility=` and the -create form carries a **Site** field, so a link from the asset explorer's -selected site lands on a form already assigned. `CreateEquipmentInput.facility` -had always accepted it — the backend validates a flat -`^/api/facilities/{uuid}$` IRI — but no frontend surface ever sent one, so the -only path was: create unassigned → detail page → assignment dialog → search the -site in a combobox. The form maps the picked id onto that IRI itself; the -`?facility=` seed writes the model rather than the field, so arriving -preselected does not start the form dirty and does not trip the -unsaved-changes guard. - -**The record is the edit surface.** Every property `UpdateEquipmentInput` -accepts (`type`, `subType`, `brand`, `model`, `serialNumber`, `locationLabel`) -opens where it is displayed, through `@shared/inplace-field` -(`EquipmentInformationPanel`, `ui/components/`); the panel owns the shared -text draft and the cancel path, the page owns the call (ARCHITECTURE.md -§10.5) and the `EquipmentEditState`/`EquipmentEditTarget` pair -(`models/equipment-edit/`) that tracks which field is open, saving, or -rejected. `type` reuses the same `EQUIPMENT_TYPE_OPTIONS` catalog as the -create form and commits on selection (`pick`); the other five are free text -with an explicit Save (`confirm`), sharing one draft signal since only one -field is ever open at a time. There is no separate edit page and no -planning wizard. - -Equipment status (`in_stock`/`operational`/`under_maintenance`/ -`decommissioned`) and maintenance-due status -(`unscheduled`/`up_to_date`/`due_soon`/`overdue`) render through this -feature's own presentation registry, `models/equipment-status-tag/` + -`ui/components/equipment-status-tag/` (`EquipmentStatusTag`, -`app-equipment-status-tag`) — named `-status-tag`, not `-tag`, because -`equipment-tag/` already names the unrelated labeling-tag resource -(`EquipmentTagOutput`). Its `status` kind reuses the exact `equipmentStatus.*` -i18n ids `interventions`' own registry already defined for the same enum -(one translation, two call sites) when it renders equipment read-only on the -intervention detail page's Linked tab. - -**Attachments, maintenance history, tags, and facility assignment are now -built** on the detail page, closing the "Deferred, not built" gap this -section used to record. Three tabs sit beside **Overview** (the identity -fields, unchanged): **Attachments** (`EquipmentAttachments`, `ui/components/`) -— upload/list/download/delete, base64 JSON on the wire -(`EquipmentService.addAttachment`'s `AddAttachmentInput.content`; the page -converts each picked `File` with the local `utils/file-to-base64/` before -calling the store) and download via `EquipmentService.downloadAttachment` -(`GET .../attachments/{attachmentId}/download`, `responseType: 'blob'`, -mirroring `InterventionService.downloadAttachment`) saved through -`BrowserDownloadService`; **Maintenance** (`EquipmentMaintenanceHistory`) — -read-only, newest-first, rendering `EquipmentMaintenanceLogOutput`'s `source` -(`'status_transition' | 'intervention'`) as an icon-and-label pair and -linking to the originating intervention (`FG-{interventionNumber}`) when -`interventionId` is present; **Tags** (`EquipmentTags`) — the current tags as -removable chips plus an `hlm-combobox` over the organization's tag catalog -that creates-or-attaches by name (`AddTagInput`) on a typed match or a "Create" -pick, mirroring `FacilityMoveDialog`'s combobox pattern. Each tab's data -loads once, on its own first activation (`EquipmentDetailPage.onTabActivated`), -mirroring `FacilityDetailPage`'s Plans tab. - -Facility assignment/unassignment (`EquipmentStore.assignToFacility` / -`unassignFromFacility`) gets its own dialog -(`EquipmentAssignFacilityDialog`, `ui/dialogs/`) opened from the header's -facility row — a single pick/clear action, not a browsing surface, so it did -not earn a fourth tab. Unassign has no separate confirm dialog, matching the -header's own Decommission action. - -There is no `/:equipmentId/edit` route: the record itself is the edit -surface (see above), and no route in this document links to one. - -## State and Data Access - -Primary stores: - -- `EquipmentStore` — provided per leaf route (list, create, detail), each - getting its own instance: unlike `interventions`, this feature has no - documented list ↔ detail state-sharing requirement (no prev/next walk), so - the simpler independently-scoped default applies (`ARCHITECTURE.md` §10.11). -- `ActiveEquipmentStore` — root-provided; the currently active record, - populated by `equipmentResolver`. - -The list page's KPI strip is backed by `EquipmentKpisStore` -(`state/equipment-kpis/`, `withQueryState`), component-scoped on the list -route and reloaded only on an organization switch. Its data comes from -`EquipmentService.kpis()` (`GET /organizations/{organizationId}/equipment/kpis`). -`EquipmentKpiOutput.openNonConformities` is organization-wide, not -equipment-scoped — non-conformities attach to inspections, not equipment — -and the strip's tile label states that explicitly rather than implying a -per-record count. - -Primary service: - -- `EquipmentService` - -Utility: - -- `buildEquipmentTitle` (`utils/equipment-title/`) — the shared "type — - brand model" title, consumed by both `equipmentTitleResolver` (document - title, breadcrumb) and `EquipmentDetailPage` (page `

`), so the two - never drift. -- `fileToBase64` (`utils/file-to-base64/`) — converts a picked `File` to the - base64 string `AddAttachmentInput.content` expects; the single consumer is - `EquipmentDetailPage`'s attachment upload handler. - -## Cross-Feature Dependencies - -- Depends on organization route context from the parent feature. -- Consumes `CollectionPagination`, `CollectionToolbar`, `CollectionSearchBox`, - `CollectionFilterBar` and `CollectionFilterToggle` from `@shared/collection-pagination`, - `@shared/collection-toolbar` and `@shared/collection-filters` for the list page's shared - pagination band, toolbar shell, search box, "Filters" toggle and editable type/status filter - chip row — see `organization/FEATURE.md` § UI Conventions. -- May be referenced by other organization subfeatures, but equipment ownership stays local to this subfeature. -- Publishes the canonical localized label and Lucide icon catalog as - `EQUIPMENT_TYPE_OPTIONS` through the feature public API (`index.ts`); the onboarding - `create-equipment-form`, organization compliance form, and the `maintenance-schedules` - subfeature's filter bar, table and campaign dialog all consume it so the equipment type - catalog is not duplicated. -- `EquipmentService` is depended on directly by the `facilities` subfeature's `FacilityPlansStore` (`listByFacility`, `setPlanPosition`) for the floor-plan editor's equipment-pin placement — the same cross-feature dependency `FacilityOverviewStore` already took on for the equipment status summary. `EquipmentService.setPlanPosition` (`PUT -/api/organizations/{organizationId}/equipment/{equipmentId}/plan-position`) - places, moves, or clears (all-null body) one equipment item's pin on its - assigned facility's floor plan; the 409 the backend returns when the - equipment carries no facility assignment is reworded client-side by the - calling store, not here. -- The reverse dependency uses the facilities feature's `FacilityOptionsStore` - for read-only site choices. No write crosses into `facilities`; assignment stays on - `EquipmentStore.assignToFacility` / `unassignFromFacility`. - -- Facility pickers (`equipment-create-form`, `equipment-assign-facility-dialog`) - take `FacilityOption[]` from the facilities feature's `models` barrel, and the - list/detail pages provide the facilities feature's `FacilityOptionsStore` - (its `state` barrel) instead of listing facilities inline — one loader, one - option shape, no raw id on a trigger. They render `FacilityOptionPicker` - from the documented `facilities/ui/components` public barrel, using server - pages of 200 and server search while keeping a chosen label outside its page. - -## Deletion (data-access only, no duplicate UI) - -`EquipmentService.remove` / `EquipmentStore.remove` call the canonical -`DELETE /api/equipment/{id}` endpoint (resolving the current revision via a -canonical `GET` first, since the organization-scoped read never carries -`revision`). For a published equipment — the only state reachable from this -app's org-scoped pages — the backend outcome is **decommissioned**, which is -exactly what the equipment-detail header's existing, non-deprecated -**Decommission** action already does through a different endpoint. To avoid -shipping two buttons with an identical outcome and permission gate, the -canonical `remove()` path is **not** wired to a second detail-page action; it -exists for data-access parity and future consumers (for example, a -draft-equipment hard-delete flow inside an intervention). If a genuinely -distinct "delete" outcome is ever needed here, revisit this decision. - -## Invariants - -- Facility's spatial views may read the approved equipment projection and position - candidates across a facility subtree, with server search and pagination. Placement - retains the real assigned facility and the original attachment's coordinate space. -- The 3D view reuses equipment type/status labels and opens equipment records through - page-owned navigation; unplaced equipment remains accessible through its list. - -- Mobile cards and touch targets follow the central interaction-capabilities contract, independent of width. - Desktop keeps table density and native menus in narrow windows. Creation uses one sheet - and one Signal Form across interaction mode changes; mobile sheets fill the available width. - -- Equipment workflows remain organization-scoped. -- Equipment state and events stay owned by this subfeature. -- Equipment lifecycle actions must respect the current equipment status. -- Pages orchestrate stores; reusable UI components must not hide equipment workflow decisions. -- A write response merges into the already-known entity (`utils/merge-equipment`) in both `EquipmentStore` and `ActiveEquipmentStore` — fields a response omits never erase known values, since API Platform omits null fields and a lifecycle Result may serialize fewer fields than the detail read. The one exception is unassign: its response's absent facility relation means unassigned, so `facilityId`/`facilityName` are cleared, never resurrected by the merge. -- A create refusal that carries no violations (the 409 plan-quota refusal) renders inline in the create form through the normalized `StoreError.message`; the store deliberately suppresses the generic error toast for quota refusals. -- `EquipmentsPage` closes the create sheet and resets the create operation only after the success navigation resolves; the sheet's own unsaved-changes gate replaces the route-level `unsavedChangesGuard`, and `?create=1` is ignored without `EQUIPMENT_WRITE`. -- The detail header keeps one primary lifecycle action; Decommission — irreversible — lives in the header's overflow menu as a destructive item and still confirms (`DESIGN.md` "Header actions"). - -## Public entry points - -These narrow entry points are published to the named consumers. `app` denotes the application composition root. Standard concern barrels follow ARCHITECTURE.md; prose examples do not grant access. - -| Entry point | Consumers | -| ------------------------------------ | -------------- | -| `ui/components/equipment-status-tag` | `organization` | +## Purpose and ownership + +Owns the organization equipment inventory, declared identity and lifecycle, type catalog, +replacement links, attachments, maintenance history and tags. The inventory remains centered +on fire equipment; `fire`, `safety` and `other` are server catalog families, not separate apps. +FireGuard's public identity and existing URLs remain unchanged. + +Inspections own control results and anomalies. Interventions own work orders and execution. +Maintenance owns operation plans and due dates. Equipment consumes their authorized read +projections and deep links; it never infers a control result from operational status or +considers a generated intervention a completed control. + +## Routes and presentation + +- `/organizations/:organizationId/equipments`: server-paged table and mobile cards, + search, type/status filters, sort preferences, CSV export, QR labels and a create sheet. +- `/equipments/create`: compatibility redirect to the list with `?create=1`, retaining + the optional facility context. Creation is permission-gated and site-scoped. +- `/equipments/types`: catalog administration, guarded by `EQUIPMENT_WRITE`. + Stable codes cannot be renamed. Revision conflicts retain the draft and require explicit + comparison with the latest server revision before retry. +- `/equipments/:equipmentId`: the equipment dossier. The resolver seeds + `ActiveEquipmentStore` without blocking activation; skeleton, failure and retry belong + to the page. The document title prefers name and asset reference, retaining historical + type/brand/model fallback. + +The dossier distinguishes declared operational state, control due status and exact open +anomalies. `maintenanceDueStatus` describes controls only; maintenance operations remain +independent and open through the plan library filtered by equipment and operation kind. +Inspection and intervention permissions are checked separately. Loading, denied and failed +secondary projections never render as zero counts or an empty confirmed work queue. + +Identity remains editable on the record through `EquipmentInformationPanel`. Name and +organization-unique asset reference are optional. The characteristics editor is a native +Signal Form for criticality and up to fifty unique key/value/unit declarations. These +values do not establish regulatory obligations. + +Overview, attachments, maintenance history and tags remain dossier sections. Secondary tabs +load on first activation. Facility assignment uses a read-only paged facility picker. +Floor-plan placement remains owned by the facility Plans tab. + +## Catalog and replacement invariants + +`EquipmentTypeCatalogStore` loads every server catalog page, cancels obsolete organization +reads and clears earlier organization choices. It can seed an authorized offline snapshot. +Secondary catalog reads are browser-only and never use broad authenticated TransferState. +All entries, including archived ones, remain available for historical labels and filters; +new creation choices exclude archived entries. + +Historical codes at revision one reuse localized labels and their existing icons. Revised +historical labels and custom labels come from the server. Custom codes use a safe icon +fallback. `EQUIPMENT_TYPE_OPTIONS` is the historical presentation registry, not the authority +for allowed equipment types. + +Replacement accepts a reserve successor or creates a new identity in the same server +transaction. The terminal move requires explicit alert-dialog confirmation after editing +the replacement sheet. A network-uncertain result retains its exact command and operation +UUID for replay; editing cannot silently create a second operation. Draft dismissal is +protected, and switching between existing/new successor choices preserves the draft. + +The historical equipment is retired, its dossier and QR remain readable, and predecessor +and successor links open their real records. Individualized reserve equipment is distinct +from future quantitative consumable stocks. + +## State and transport + +Each leaf route provides its own `EquipmentStore`; `ActiveEquipmentStore` remains the +root current-record source. The page provides independent catalog, replacement, +inspection-summary and open-work stores with explicit request states. + +`EquipmentService` owns the equipment transport and consumes owner read endpoints: + +- Exact inventory/facility summaries share matching family/customer/due filters and + subtree scope with the corresponding list; counts never depend on page size. +- `inspection-summary` returns authorized published control evidence and exact anomaly counts. +- `open-work` returns authorized intervention/work-item identities without pagination. +- `replace` sends one stable atomic command and reads its receipt. + +The dossier reuses open work of the same action before offering a new intervention. +Intervention links carry `targetEquipment` and `workAction`; new preparation additionally +uses `create=1`. Observed defects reuse the equipment-scoped inspection creation workflow. +No unsupported equipment filter is invented on the intervention list. + +Write responses merge with known records rather than erase fields omitted by serialization. +Accepted equipment writes keep their initiating organization, equipment and selection generation. +Their confirmed result may update that equipment's local cache, but only the same dossier visit +can synchronize the active record. The dossier renders and offers actions only for a record +matching both route identities; changing the route clears transient edits and dialog targets. +An accepted explicit-null detail patch clears the requested field even when the response +omits nulls; unrelated omitted fields remain unchanged. Unassignment always clears the +facility relation. Lifecycle actions respect the terminal retired state. + +CSV export covers the complete inventory and warns when list filters are ignored. +QR printing retains explicit inventory/facility/selection scopes and server preview limits. +Equipment reports and attachment downloads keep the existing entitlement and transport +contracts. Published equipment deletion remains decommissioning; there is no duplicate +delete button or standalone edit route. + +## Public APIs and boundaries + +The feature `index.ts` publishes the historical options, `EquipmentTypeCatalogStore` +and its instance type, catalog output/option/family types, and `buildEquipmentTitle`. +Organization assets, onboarding, compliance settings, facilities, maintenance schedules, +service requests and procurement +may consume these contracts. Models remain type-only except the existing status registries. + +`data-access` publishes stable services. Facilities may read equipment, exact summaries +and plan positions. Maintenance schedules may read equipment choices. Inspection and work +projections are permission-gated server resources, rather than cross-feature private store imports. + +Service requests may use the published equipment transport, identity models and title utility +to qualify a repair target and reuse open work. Procurement may use the authorized catalog +and its public models to declare equipment that will be individualized by the server. +Quantitative parts remain Inventory-owned; reserve equipment does not contribute to those balances. + +Facility options and assignment selectors use the facilities feature's approved public APIs. +Organization setup may consume the catalog transport and pure Equipment-owned option mapping +through its façade. Onboarding consumes that setup contract rather than Equipment internals. +Pages own requests, navigation and mutations; forms, dialogs, sheets and tables consume +inputs and emit intent. Native Spartan primitives, Signal Forms, localization, SSR and +central interaction capabilities remain mandatory. + +### Published entry points + +| Entry point | Consumers | +| --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `index.ts` | `organization`, `onboarding`, `organization/features/facilities`, `organization/features/maintenance-schedules`, `organization/features/procurement`, `organization/features/service-requests` | +| `data-access` | `organization`, `organization/features/facilities`, `organization/features/maintenance-schedules`, `organization/features/inspections`, `organization/features/service-requests`, `organization/features/maintenance-exports` | +| `models` | `organization/features/service-requests`, `organization/features/procurement`, `organization/features/maintenance-exports` | +| `utils` | `organization`, `organization/features/service-requests` | +| `ui/components` | `organization` | + +## Verification + +Transport tests cover catalog pagination, optimistic revisions and atomic replacement +payloads. Store tests cover organization changes, stale reads, uncertain command replay +and duplicate submissions. Form tests cover normalized unique properties and retained +drafts. Dossier tests distinguish operational/control/anomaly states and authorized work. +Browser scenarios cover catalog administration and replacement on desktop and mobile. diff --git a/src/app/features/organization/features/equipments/data-access/index.ts b/src/app/features/organization/features/equipments/data-access/index.ts index 4c5803fed..168bf8140 100644 --- a/src/app/features/organization/features/equipments/data-access/index.ts +++ b/src/app/features/organization/features/equipments/data-access/index.ts @@ -1 +1,2 @@ export { EquipmentService } from './services/equipment/equipment.service'; +export { EquipmentTypeService } from './services/equipment-type/equipment-type.service'; diff --git a/src/app/features/organization/features/equipments/data-access/services/equipment-type/equipment-type.service.ts b/src/app/features/organization/features/equipments/data-access/services/equipment-type/equipment-type.service.ts new file mode 100644 index 000000000..d7f1c1995 --- /dev/null +++ b/src/app/features/organization/features/equipments/data-access/services/equipment-type/equipment-type.service.ts @@ -0,0 +1,127 @@ +import { Service } from '@angular/core'; +import { defer, EMPTY, expand, reduce, type Observable } from 'rxjs'; +import { HydraApiService, type RequestOptions } from '@core/api'; +import type { HydraCollection } from '@core/api/models'; +import type { + CreateEquipmentTypeInput, + EquipmentTypeOutput, + UpdateEquipmentTypeInput, +} from '@features/organization/features/equipments/models'; + +/** + * Class EquipmentTypeService + * @class EquipmentTypeService + * + * @description + * Reads the server-owned organization equipment catalog. + */ +@Service() +export class EquipmentTypeService extends HydraApiService { + //#region Methods + /** + * Method list + * + * @description + * Lists active and archived entries so historical records retain their labels. + * + * @access public + * @since unreleased + * + * @param {string} organizationId - Organization owning the catalog. + * @param {RequestOptions} options - Optional transport query settings. + * + * @returns {Observable>} Server catalog. + */ + public list( + organizationId: string, + options?: RequestOptions, + ): Observable> { + return this.getCollection( + `/api/organizations/${organizationId}/equipment-types`, + options, + ); + } + /** + * Method listAll + * + * @description + * Reads the complete collection using actual progress, including a non-paginated server response. + * + * @access public + * @since unreleased + * + * @param {string} organizationId - Organization owning the catalog. + * + * @returns {Observable} Complete transport catalog. + */ + public listAll(organizationId: string): Observable { + const itemsPerPage = 200; + return defer(() => { + let received = 0; + return this.list(organizationId, { itemsPerPage, page: 1 }).pipe( + expand((collection, pageIndex) => { + received += collection.member.length; + return collection.member.length > 0 && received < collection.totalItems + ? this.list(organizationId, { itemsPerPage, page: pageIndex + 2 }) + : EMPTY; + }), + reduce( + (entries, collection) => [...entries, ...collection.member], + [] as readonly EquipmentTypeOutput[], + ), + ); + }); + } + /** + * Method create + * @method create + * + * @description + * Adds one permanent code to the organization's catalogue. + * + * @access public + * @since unreleased + * + * @param {string} organizationId - Organization owning the catalogue. + * @param {CreateEquipmentTypeInput} input - Validated type identity and family. + * + * @returns {Observable} Confirmed server descriptor. + */ + public create( + organizationId: string, + input: CreateEquipmentTypeInput, + ): Observable { + return this.post( + `/api/organizations/${organizationId}/equipment-types`, + input, + ); + } + + /** + * Method update + * @method update + * + * @description + * Changes or archives a descriptor using the revision reviewed by the operator. + * + * @access public + * @since unreleased + * + * @param {string} organizationId - Organization owning the catalogue. + * @param {string} typeCode - Immutable equipment type code. + * @param {UpdateEquipmentTypeInput} input - Revision and explicit changes. + * + * @returns {Observable} Confirmed server descriptor. + */ + public update( + organizationId: string, + typeCode: string, + input: UpdateEquipmentTypeInput, + ): Observable { + return this.patch( + `/api/organizations/${organizationId}/equipment-types/${encodeURIComponent(typeCode)}`, + input, + ); + } + //#endregion +} diff --git a/src/app/features/organization/features/equipments/data-access/services/equipment-type/testing/equipment-type.service.spec.ts b/src/app/features/organization/features/equipments/data-access/services/equipment-type/testing/equipment-type.service.spec.ts new file mode 100644 index 000000000..d793cd4b0 --- /dev/null +++ b/src/app/features/organization/features/equipments/data-access/services/equipment-type/testing/equipment-type.service.spec.ts @@ -0,0 +1,119 @@ +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { ENV_CONFIG } from '@core/config/environment/env.token'; +import type { EquipmentTypeOutput } from '@features/organization/features/equipments/models'; +import { EquipmentTypeService } from '../equipment-type.service'; + +describe('EquipmentTypeService', () => { + let service: EquipmentTypeService; + let http: HttpTestingController; + const url = 'https://api.test/api/organizations/org-1/equipment-types'; + const entry: EquipmentTypeOutput = { + '@id': '/api/organizations/org-1/equipment-types/fire_blanket', + '@type': 'EquipmentType', + value: 'fire_blanket', + label: 'Fire blanket', + family: 'fire', + archived: false, + revision: 1, + }; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + EquipmentTypeService, + { provide: ENV_CONFIG, useValue: { apiUrl: 'https://api.test' } }, + ], + }); + service = TestBed.inject(EquipmentTypeService); + http = TestBed.inject(HttpTestingController); + }); + afterEach(() => http.verify()); + + it('creates a catalogue type inside the organization', () => { + const input = { value: entry.value, label: entry.label, family: entry.family }; + let result: EquipmentTypeOutput | undefined; + service.create('org-1', input).subscribe((value) => { + result = value; + }); + const request = http.expectOne(url); + expect(request.request.method).toBe('POST'); + expect(request.request.body).toEqual(input); + request.flush(entry); + expect(result).toEqual(entry); + }); + + it.each([ + { label: 'Blanket' }, + { family: 'safety' as const }, + { archived: true }, + { archived: false }, + ])('merge-patches explicit descriptor changes with the reviewed revision %s', (changes) => { + service.update('org-1', entry.value, { revision: 4, ...changes }).subscribe(); + const request = http.expectOne(`${url}/${entry.value}`); + expect(request.request.method).toBe('PATCH'); + expect(request.request.headers.get('Content-Type')).toBe('application/merge-patch+json'); + expect(request.request.body).toEqual({ revision: 4, ...changes }); + expect(request.request.body).not.toHaveProperty('value'); + request.flush({ ...entry, ...changes, revision: 5 }); + }); + + it('propagates a revision conflict without retrying a command', () => { + let failure: unknown; + service.update('org-1', entry.value, { revision: 1, archived: true }).subscribe({ + error: (error: unknown) => { + failure = error; + }, + }); + http + .expectOne(`${url}/${entry.value}`) + .flush( + { status: 409, title: 'Conflict', detail: 'Equipment type revision is stale.' }, + { status: 409, statusText: 'Conflict' }, + ); + expect(failure).toMatchObject({ status: 409, detail: 'Equipment type revision is stale.' }); + http.expectNone(`${url}/${entry.value}`); + }); + + it('reads all pages, including archived descriptors, before resolving the catalogue', () => { + let result: readonly EquipmentTypeOutput[] | undefined; + service.listAll('org-1').subscribe((entries) => { + result = entries; + }); + const first = http.expectOne( + (request) => request.url === url && request.params.get('page') === '1', + ); + expect(first.request.params.get('itemsPerPage')).toBe('200'); + const firstPage: EquipmentTypeOutput[] = Array.from({ length: 200 }, (_, index) => ({ + ...entry, + value: `type_${index}`, + })); + first.flush({ member: firstPage, totalItems: 201 }); + expect(result).toBeUndefined(); + const second = http.expectOne( + (request) => request.url === url && request.params.get('page') === '2', + ); + const archived = { ...entry, value: 'old_type', archived: true }; + second.flush({ member: [archived], totalItems: 201 }); + expect(result).toEqual([...firstPage, archived]); + }); + + it('reads an unpaginated catalog with more than two hundred descriptors exactly once', () => { + const descriptors: EquipmentTypeOutput[] = Array.from({ length: 201 }, (_, index) => ({ + ...entry, + value: `type_${index}`, + })); + let result: readonly EquipmentTypeOutput[] | undefined; + service.listAll('org-1').subscribe((entries) => { + result = entries; + }); + const request = http.expectOne((candidate) => candidate.url === url); + request.flush({ member: descriptors, totalItems: descriptors.length }); + expect(result).toEqual(descriptors); + expect(result).toHaveLength(201); + http.expectNone((candidate) => candidate.url === url); + }); +}); diff --git a/src/app/features/organization/features/equipments/data-access/services/equipment/equipment.service.ts b/src/app/features/organization/features/equipments/data-access/services/equipment/equipment.service.ts index 8a315d7a6..c43638f15 100644 --- a/src/app/features/organization/features/equipments/data-access/services/equipment/equipment.service.ts +++ b/src/app/features/organization/features/equipments/data-access/services/equipment/equipment.service.ts @@ -15,6 +15,10 @@ import type { SetPlanPositionInput, EquipmentKpiOutput, EquipmentFacilitySummaryOutput, + EquipmentInspectionSummaryOutput, + EquipmentOpenWorkOutput, + ReplaceEquipmentInput, + ReplaceEquipmentOutput, } from '@features/organization/features/equipments/models'; /** @@ -53,6 +57,76 @@ export class EquipmentService extends HydraApiService { //#endregion //#region Methods + /** + * Method openWork + * + * @description + * Reads authorized intervention-owned work without inferring an empty queue from missing rights. + * + * @access public + * @since unreleased + * + * @param {string} organizationId - Organization owning the equipment. + * @param {string} equipmentId - Equipment targeted by the work. + * + * @returns {Observable>} Authorized open work. + */ + public openWork( + organizationId: string, + equipmentId: string, + ): Observable> { + return this.getCollection( + `${this.equipmentPath(organizationId, equipmentId)}/open-work`, + ); + } + /** + * Method inspectionSummary + * + * @description + * Reads the Inspection-owned exact summary requiring both equipment and inspection read access. + * + * @access public + * @since unreleased + * + * @param {string} organizationId - Organization owning the equipment. + * @param {string} equipmentId - Equipment whose evidence is requested. + * + * @returns {Observable} Last control and unresolved anomalies. + */ + public inspectionSummary( + organizationId: string, + equipmentId: string, + ): Observable { + return this.getOne( + `${this.equipmentPath(organizationId, equipmentId)}/inspection-summary`, + ); + } + /** + * Method replace + * + * @description + * Atomically retires the historical identity and installs its successor. + * + * @access public + * @since unreleased + * + * @param {string} organizationId - Organization owning both identities. + * @param {string} equipmentId - Historical equipment to retire. + * @param {ReplaceEquipmentInput} input - Stable command reused after network uncertainty. + * + * @returns {Observable} Replacement receipt. + */ + public replace( + organizationId: string, + equipmentId: string, + input: ReplaceEquipmentInput, + ): Observable { + return this.http.post( + this.buildUrl(`${this.equipmentPath(organizationId, equipmentId)}/replace`), + input, + { headers: this.buildHeaders(), withCredentials: true }, + ); + } /** * Method equipmentPath * @method equipmentPath @@ -235,6 +309,30 @@ export class EquipmentService extends HydraApiService { ); } + /** + * Method summary + * + * @description + * Reads exact organization or customer equipment counts using the collection's server filters. + * + * @access public + * @since unreleased + * + * @param {string} organizationId - Owning organization. + * @param {RequestOptions} options - Family and optional customer query. + * + * @returns {Observable} Authoritative counts across all pages. + */ + public summary( + organizationId: string, + options?: RequestOptions, + ): Observable { + return this.getOne( + `${EquipmentService.BASE_PATH}/${organizationId}/equipment-summary`, + options, + ); + } + /** * Method summaryByFacility * @method summaryByFacility @@ -248,6 +346,7 @@ export class EquipmentService extends HydraApiService { * @param {string} organizationId - Owning organization. * @param {string} facilityId - Selected facility. * @param {boolean} includeDescendants - Include equipment assigned to descendant facilities. + * @param {RequestOptions} options - Family and optional customer query. * * @returns {Observable} Authoritative counts across all pages. */ @@ -255,10 +354,11 @@ export class EquipmentService extends HydraApiService { organizationId: string, facilityId: string, includeDescendants: boolean = true, + options?: RequestOptions, ): Observable { return this.getOne( `${EquipmentService.BASE_PATH}/${organizationId}/facilities/${facilityId}/equipment-summary`, - { params: { includeDescendants } }, + { ...options, params: { ...options?.params, includeDescendants } }, ); } diff --git a/src/app/features/organization/features/equipments/data-access/services/equipment/testing/equipment.service.spec.ts b/src/app/features/organization/features/equipments/data-access/services/equipment/testing/equipment.service.spec.ts index 7003a97c9..f5a832ce7 100644 --- a/src/app/features/organization/features/equipments/data-access/services/equipment/testing/equipment.service.spec.ts +++ b/src/app/features/organization/features/equipments/data-access/services/equipment/testing/equipment.service.spec.ts @@ -47,6 +47,45 @@ describe('EquipmentService', () => { httpMock.verify(); }); + it('posts one stable atomic replacement command without creating a separate equipment first', () => { + const input = { + clientOperationId: '94ed10c6-55de-4853-8027-d2d6f5da6cbb', + successor: { type: 'fire_extinguisher', name: 'Emergency entrance', assetCode: 'EXT-3' }, + }; + const next = vi.fn(); + service.replace(orgId, equipmentId, input).subscribe(next); + const request = httpMock.expectOne(`${equipmentBaseUrl}/${equipmentId}/replace`); + expect(request.request.method).toBe('POST'); + expect(request.request.body).toEqual(input); + expect(request.request.withCredentials).toBe(true); + const receipt = { + clientOperationId: input.clientOperationId, + predecessorEquipmentId: equipmentId, + successorEquipmentId: 'next-equipment', + replayed: false, + }; + request.flush(receipt); + expect(next).toHaveBeenCalledWith(receipt); + httpMock.expectNone( + (candidate) => candidate.url === equipmentBaseUrl && candidate.method === 'POST', + ); + }); + + it('reads exact inspection evidence from the authorized equipment summary', () => { + const next = vi.fn(); + service.inspectionSummary(orgId, equipmentId).subscribe(next); + const request = httpMock.expectOne(`${equipmentBaseUrl}/${equipmentId}/inspection-summary`); + expect(request.request.method).toBe('GET'); + request.flush({ + '@id': request.request.url, + '@type': 'EquipmentInspectionSummary', + equipmentId, + openAnomalies: 2, + bySeverity: { low: 0, medium: 1, high: 1, critical: 0 }, + }); + expect(next).toHaveBeenCalledWith(expect.objectContaining({ equipmentId, openAnomalies: 2 })); + }); + it('rejects an empty explicit label selection without issuing an inventory request', () => { const failed = vi.fn(); service.exportLabels(orgId, { ids: [] }).subscribe({ error: failed }); diff --git a/src/app/features/organization/features/equipments/equipments.routes.ts b/src/app/features/organization/features/equipments/equipments.routes.ts index c85937ad6..90a74762f 100644 --- a/src/app/features/organization/features/equipments/equipments.routes.ts +++ b/src/app/features/organization/features/equipments/equipments.routes.ts @@ -2,6 +2,7 @@ import { inject } from '@angular/core'; import { Router, type RedirectFunction, type Routes } from '@angular/router'; import { organizationPermissionGuard } from '@features/organization/http/guards'; import { ORGANIZATION_PERMISSION } from '@features/organization/models'; +import { unsavedChangesGuard } from '@shared/unsaved-changes'; import { equipmentResolver, equipmentTitleResolver } from './http/resolvers'; import { EquipmentKpisStore, EquipmentStore } from './state'; @@ -33,7 +34,6 @@ const redirectToCreateSheet: RedirectFunction = (redirectData) => { /** * Constant EQUIPMENT_ROUTES - * @const EQUIPMENT_ROUTES * * @description * Organization-scoped equipment workflows: the index at @@ -65,6 +65,8 @@ const redirectToCreateSheet: RedirectFunction = (redirectData) => { * @since 1.0.0 * * @type {Routes} + * + * @const EQUIPMENT_ROUTES */ export const EQUIPMENT_ROUTES: Routes = [ { @@ -89,6 +91,19 @@ export const EQUIPMENT_ROUTES: Routes = [ path: 'create', redirectTo: redirectToCreateSheet, }, + { + path: 'types', + canActivate: [ + organizationPermissionGuard({ permissions: [ORGANIZATION_PERMISSION.EQUIPMENT_WRITE] }), + ], + canDeactivate: [unsavedChangesGuard], + loadComponent: () => + import('./ui/pages/equipment-types-page/equipment-types-page.component').then( + (m) => m.EquipmentTypesPage, + ), + title: $localize`:@@equipment.catalog.title:Equipment types`, + data: { breadcrumb: $localize`:@@equipment.catalog.title:Equipment types` }, + }, { path: ':equipmentId', providers: [EquipmentStore], diff --git a/src/app/features/organization/features/equipments/index.ts b/src/app/features/organization/features/equipments/index.ts index 76d226624..e0f1ec0cd 100644 --- a/src/app/features/organization/features/equipments/index.ts +++ b/src/app/features/organization/features/equipments/index.ts @@ -1 +1,7 @@ export { EQUIPMENT_TYPE_OPTIONS } from './options'; +export { + EquipmentTypeCatalogStore, + type EquipmentTypeCatalogStoreType, +} from './state/equipment-type-catalog'; +export type { EquipmentTypeOption, EquipmentTypeOutput, EquipmentFamily } from './models'; +export { buildEquipmentTitle } from './utils'; diff --git a/src/app/features/organization/features/equipments/models/equipment-edit/equipment-edit-target.type.ts b/src/app/features/organization/features/equipments/models/equipment-edit/equipment-edit-target.type.ts index 3344dfdbe..d9d8a8b83 100644 --- a/src/app/features/organization/features/equipments/models/equipment-edit/equipment-edit-target.type.ts +++ b/src/app/features/organization/features/equipments/models/equipment-edit/equipment-edit-target.type.ts @@ -6,8 +6,13 @@ * it is displayed (`ARCHITECTURE.md` §10.5). * * @since 1.0.0 + * + * @type {EquipmentEditTarget} */ export type EquipmentEditTarget = + | 'name' + | 'assetCode' + | 'technicalProperties' | 'type' | 'subType' | 'brand' diff --git a/src/app/features/organization/features/equipments/models/equipment-summary/equipment-facility-summary-output.interface.ts b/src/app/features/organization/features/equipments/models/equipment-summary/equipment-facility-summary-output.interface.ts index 59ddfa262..26133b1da 100644 --- a/src/app/features/organization/features/equipments/models/equipment-summary/equipment-facility-summary-output.interface.ts +++ b/src/app/features/organization/features/equipments/models/equipment-summary/equipment-facility-summary-output.interface.ts @@ -18,9 +18,9 @@ export interface EquipmentFacilitySummaryOutput extends HydraItem { * @description * Whether the counts include descendant facilities. * - * @type {'subtree' | 'direct'} + * @type {'subtree' | 'direct' | 'organization' | 'customer'} */ - readonly scope: 'subtree' | 'direct'; + readonly scope: 'subtree' | 'direct' | 'organization' | 'customer'; /** * Property totalItems diff --git a/src/app/features/organization/features/equipments/models/equipment-type/create-equipment-type-input.interface.ts b/src/app/features/organization/features/equipments/models/equipment-type/create-equipment-type-input.interface.ts new file mode 100644 index 000000000..9f61c0b17 --- /dev/null +++ b/src/app/features/organization/features/equipments/models/equipment-type/create-equipment-type-input.interface.ts @@ -0,0 +1,43 @@ +import type { EquipmentFamily } from './equipment-type-output.interface'; + +/** + * Interface CreateEquipmentTypeInput + * @interface CreateEquipmentTypeInput + * + * @description + * Creates one organization-owned equipment type with a permanent code. + */ +export interface CreateEquipmentTypeInput { + /** + * Property value + * @readonly + * + * @description + * Permanent lowercase catalogue code, limited to 32 characters. + * + * @type {string} + */ + readonly value: string; + + /** + * Property label + * @readonly + * + * @description + * Organization display label, limited to 100 characters. + * + * @type {string} + */ + readonly label: string; + + /** + * Property family + * @readonly + * + * @description + * Family used to scope the equipment inventory. + * + * @type {EquipmentFamily} + */ + readonly family: EquipmentFamily; +} diff --git a/src/app/features/organization/features/equipments/models/equipment-type/equipment-type-output.interface.ts b/src/app/features/organization/features/equipments/models/equipment-type/equipment-type-output.interface.ts new file mode 100644 index 000000000..d1761cdc5 --- /dev/null +++ b/src/app/features/organization/features/equipments/models/equipment-type/equipment-type-output.interface.ts @@ -0,0 +1,77 @@ +import type { HydraItem } from '@core/api/models'; + +/** + * Type EquipmentFamily + * + * @description + * Server-owned family used to scope the fire inventory. + * + * @type {EquipmentFamily} + */ +export type EquipmentFamily = 'fire' | 'safety' | 'other'; + +/** + * Interface EquipmentTypeOutput + * @interface EquipmentTypeOutput + * + * @description + * Organization-owned catalog entry returned by the server. + */ +export interface EquipmentTypeOutput extends HydraItem { + /** + * Property value + * + * @description + * Stable equipment type code. + */ + readonly value: string; + + /** + * Property label + * + * @description + * Catalog display label. + */ + readonly label: string; + + /** + * Property family + * + * @description + * Inventory family assigned to this type. + */ + readonly family: EquipmentFamily; + + /** + * Property archived + * + * @description + * Archived types remain readable but cannot be newly assigned. + */ + readonly archived: boolean; + + /** + * Property revision + * + * @description + * Optimistic concurrency revision of the catalog entry. + */ + readonly revision: number; +} + +/** + * Interface EquipmentTypeOption + * @interface EquipmentTypeOption + * + * @description + * Type picker option derived from the organization catalog. + */ +export interface EquipmentTypeOption extends EquipmentTypeOutput { + /** + * Property icon + * + * @description + * Registered Lucide icon, with a safe fallback for custom codes. + */ + readonly icon: string; +} diff --git a/src/app/features/organization/features/equipments/models/equipment-type/update-equipment-type-input.interface.ts b/src/app/features/organization/features/equipments/models/equipment-type/update-equipment-type-input.interface.ts new file mode 100644 index 000000000..6985b50ee --- /dev/null +++ b/src/app/features/organization/features/equipments/models/equipment-type/update-equipment-type-input.interface.ts @@ -0,0 +1,54 @@ +import type { EquipmentFamily } from './equipment-type-output.interface'; + +/** + * Interface UpdateEquipmentTypeInput + * @interface UpdateEquipmentTypeInput + * + * @description + * Revision-checked catalogue update; permanent codes cannot be patched. + */ +export interface UpdateEquipmentTypeInput { + /** + * Property revision + * @readonly + * + * @description + * Revision reviewed by the operator before saving. + * + * @type {number} + */ + readonly revision: number; + + /** + * Property label + * @readonly + * + * @description + * Optional replacement label. + * + * @type {string | undefined} + */ + readonly label?: string; + + /** + * Property family + * @readonly + * + * @description + * Optional replacement inventory family. + * + * @type {EquipmentFamily | undefined} + */ + readonly family?: EquipmentFamily; + + /** + * Property archived + * @readonly + * + * @description + * Archived entries remain readable and can be explicitly restored. + * + * @type {boolean | undefined} + */ + readonly archived?: boolean; +} diff --git a/src/app/features/organization/features/equipments/models/equipment/create-equipment-input.interface.ts b/src/app/features/organization/features/equipments/models/equipment/create-equipment-input.interface.ts index 5cd184493..aba59e471 100644 --- a/src/app/features/organization/features/equipments/models/equipment/create-equipment-input.interface.ts +++ b/src/app/features/organization/features/equipments/models/equipment/create-equipment-input.interface.ts @@ -10,7 +10,16 @@ import type { EquipmentOutput } from './equipment-output.interface'; */ type EquipmentEditableFields = Pick< EquipmentOutput, - 'type' | 'subType' | 'brand' | 'model' | 'serialNumber' | 'locationLabel' + | 'type' + | 'subType' + | 'brand' + | 'model' + | 'serialNumber' + | 'locationLabel' + | 'name' + | 'assetCode' + | 'criticality' + | 'technicalProperties' >; /** @@ -67,6 +76,8 @@ export type CreateEquipmentInput = Pick & * Property facility * @readonly * + * @description + * * @access public * * @type {string} diff --git a/src/app/features/organization/features/equipments/models/equipment/equipment-inspection-summary-output.interface.ts b/src/app/features/organization/features/equipments/models/equipment/equipment-inspection-summary-output.interface.ts new file mode 100644 index 000000000..94bbd60d5 --- /dev/null +++ b/src/app/features/organization/features/equipments/models/equipment/equipment-inspection-summary-output.interface.ts @@ -0,0 +1,58 @@ +import type { HydraItem } from '@core/api/models'; + +/** + * Interface EquipmentInspectionSummaryOutput + * @interface EquipmentInspectionSummaryOutput + * + * @description + * Inspection-owned evidence and unresolved anomaly counts for one authorized equipment. + */ +export interface EquipmentInspectionSummaryOutput extends HydraItem { + /** + * Property equipmentId + * + * @description + * Equipment covered by this exact summary. + */ + readonly equipmentId: string; + + /** + * Property openAnomalies + * + * @description + * Server-confirmed unresolved anomalies; absent authorization is never a zero. + */ + readonly openAnomalies: number; + + /** + * Property bySeverity + * + * @description + * Unresolved anomaly counts split by their declared severity. + */ + readonly bySeverity: Readonly>; + + /** + * Property lastInspectionId + * + * @description + * Most recent inspection whose published evidence can be opened. + */ + readonly lastInspectionId?: string | null; + + /** + * Property lastInspectionPerformedAt + * + * @description + * Actual performance date of the last published inspection. + */ + readonly lastInspectionPerformedAt?: string | null; + + /** + * Property lastInspectionResult + * + * @description + * Published result, independent of the declared operational status. + */ + readonly lastInspectionResult?: 'pass' | 'fail' | 'partial' | null; +} diff --git a/src/app/features/organization/features/equipments/models/equipment/equipment-open-work-output.interface.ts b/src/app/features/organization/features/equipments/models/equipment/equipment-open-work-output.interface.ts new file mode 100644 index 000000000..edefbfcb3 --- /dev/null +++ b/src/app/features/organization/features/equipments/models/equipment/equipment-open-work-output.interface.ts @@ -0,0 +1,66 @@ +import type { HydraItem } from '@core/api/models'; + +/** + * Interface EquipmentOpenWorkOutput + * @interface EquipmentOpenWorkOutput + * + * @description + * Intervention-owned open work projection for one authorized equipment. + */ +export interface EquipmentOpenWorkOutput extends HydraItem { + /** + * Property interventionId + * + * @description + * Authorized intervention containing this work. + */ + readonly interventionId: string; + + /** + * Property number + * + * @description + * Human-readable intervention number, when assigned. + */ + readonly number?: number | null; + + /** + * Property name + * + * @description + * Display name of the authorized intervention. + */ + readonly name?: string | null; + + /** + * Property status + * + * @description + * Intervention workflow status, independent of equipment lifecycle. + */ + readonly status: string; + + /** + * Property workItemId + * + * @description + * Stable work item identity revealed by the intervention deep link. + */ + readonly workItemId: string; + + /** + * Property action + * + * @description + * Work operation offered by the intervention module. + */ + readonly action: string; + + /** + * Property workItemStatus + * + * @description + * Execution state of the specific equipment work item. + */ + readonly workItemStatus: string; +} diff --git a/src/app/features/organization/features/equipments/models/equipment/equipment-output.interface.ts b/src/app/features/organization/features/equipments/models/equipment/equipment-output.interface.ts index babb48ab9..46bb816a8 100644 --- a/src/app/features/organization/features/equipments/models/equipment/equipment-output.interface.ts +++ b/src/app/features/organization/features/equipments/models/equipment/equipment-output.interface.ts @@ -8,18 +8,20 @@ import type { EquipmentMaintenanceDueStatus } from './equipment-maintenance-due- * @description * Supported lifecycle statuses for an equipment * resource. + * + * @type {EquipmentStatus} */ export type EquipmentStatus = 'in_stock' | 'operational' | 'decommissioned' | 'under_maintenance'; /** - * Type EquipmentType + * Type HistoricalEquipmentType * * @description - * Supported equipment types (mirrors the backend `EquipmentType` value - * object). Used to constrain the type select in the create/edit form and - * the type column filter, replacing free-text entry that produced a 400. + * Historical equipment codes retained for compatibility and localized labels. + * + * @type {HistoricalEquipmentType} */ -export type EquipmentType = +export type HistoricalEquipmentType = | 'fire_extinguisher' | 'smoke_detector' | 'heat_detector' @@ -33,6 +35,59 @@ export type EquipmentType = | 'gas_detector' | 'other'; +/** + * Type EquipmentType + * + * @description + * Stable catalog code, including organization-owned types. + * + * @type {EquipmentType} + */ +export type EquipmentType = string; + +/** + * Type EquipmentCriticality + * + * @description + * Declared business impact when equipment is unavailable. + * + * @type {EquipmentCriticality} + */ +export type EquipmentCriticality = 'low' | 'medium' | 'high' | 'critical'; + +/** + * Interface EquipmentTechnicalProperty + * @interface EquipmentTechnicalProperty + * + * @description + * Declarative characteristic; it never infers a regulatory obligation. + */ +export interface EquipmentTechnicalProperty { + /** + * Property key + * + * @description + * Characteristic name, unique within the equipment. + */ + readonly key: string; + + /** + * Property value + * + * @description + * Human-readable declared value. + */ + readonly value: string; + + /** + * Property unit + * + * @description + * Optional unit associated with the declared value. + */ + readonly unit?: string | null; +} + /** * Interface EquipmentPlanPosition * @interface EquipmentPlanPosition @@ -43,11 +98,32 @@ export type EquipmentType = */ export interface EquipmentPlanPosition { //#region Properties - /** @type {string} */ + /** + * Property attachmentId + * + * @description + * Floor-plan attachment owning the normalized coordinate space. + * + * @type {string} + */ readonly attachmentId: string; - /** @type {number} */ + /** + * Property x + * + * @description + * Horizontal coordinate normalized between zero and one. + * + * @type {number} + */ readonly x: number; - /** @type {number} */ + /** + * Property y + * + * @description + * Vertical coordinate normalized between zero and one. + * + * @type {number} + */ readonly y: number; //#endregion } @@ -61,15 +137,102 @@ export interface EquipmentPlanPosition { */ export interface EquipmentOutput extends HydraItem { /** - * Optional intervention IRI when equipment belongs to a intervention-scoped workflow. + * Property controlDueStatus + * + * @description + * Independent control operation due state; the historical maintenance status remains its alias. + */ + readonly controlDueStatus?: EquipmentMaintenanceDueStatus; + + /** + * Property serviceDueStatus + * + * @description + * Independent maintenance operation due state, without affecting the control calendar. + */ + readonly serviceDueStatus?: EquipmentMaintenanceDueStatus; + + /** + * Property controlNextDueAt + * + * @description + * Earliest server-computed next control deadline, absent when no control plan applies. + */ + readonly controlNextDueAt?: string | null; + + /** + * Property serviceNextDueAt + * + * @description + * Earliest server-computed maintenance deadline, independent from control deadlines. + */ + readonly serviceNextDueAt?: string | null; + /** + * Property name + * + * @description + * Optional human-readable equipment name. + */ + readonly name?: string | null; + + /** + * Property assetCode + * + * @description + * Organization-unique asset reference when supplied. + */ + readonly assetCode?: string | null; + + /** + * Property criticality + * + * @description + * Declared operational impact of equipment unavailability. + */ + readonly criticality?: EquipmentCriticality | null; + + /** + * Property technicalProperties + * + * @description + * Declarative characteristics with optional units. + */ + readonly technicalProperties?: readonly EquipmentTechnicalProperty[]; + + /** + * Property predecessorEquipmentId + * + * @description + * Historical equipment replaced by this equipment. + */ + readonly predecessorEquipmentId?: string | null; + + /** + * Property successorEquipmentId + * + * @description + * Equipment that succeeded this retired equipment. + */ + readonly successorEquipmentId?: string | null; + /** + * Property intervention + * + * @description + * Optional intervention IRI for a draft equipment created within a work order. */ readonly intervention?: string | null; /** - * Record lifecycle state used by draft/publish intervention workflows. + * Property recordStatus + * + * @description + * Draft or published record state used during intervention publication. */ readonly recordStatus?: 'draft' | 'published'; /** - * Monotonic revision returned by backend for optimistic publication checks. + * Property revision + * + * @description + * Monotonic revision used for optimistic publication checks. */ readonly revision?: number; //#region Properties diff --git a/src/app/features/organization/features/equipments/models/equipment/replace-equipment-input.interface.ts b/src/app/features/organization/features/equipments/models/equipment/replace-equipment-input.interface.ts new file mode 100644 index 000000000..1530ab088 --- /dev/null +++ b/src/app/features/organization/features/equipments/models/equipment/replace-equipment-input.interface.ts @@ -0,0 +1,76 @@ +import type { CreateEquipmentInput } from './create-equipment-input.interface'; + +/** + * Type ReplacementEquipmentInput + * + * @description + * Identity of a new successor. Organization and location ownership are inherited atomically. + * + * @type {ReplacementEquipmentInput} + */ +export type ReplacementEquipmentInput = Omit< + CreateEquipmentInput, + 'clientId' | 'organization' | 'intervention' | 'facility' +>; + +/** + * Type ReplaceEquipmentInput + * + * @description + * Stable replacement command naming either an existing successor or a new identity. + * + * @type {ReplaceEquipmentInput} + */ +export type ReplaceEquipmentInput = { + /** + * Property clientOperationId + * + * @description + * Stable UUID retained across retries of the same replacement. + */ + readonly clientOperationId: string; +} & ( + | { readonly successorEquipmentId: string; readonly successor?: never } + | { readonly successorEquipmentId?: null; readonly successor: ReplacementEquipmentInput } +); + +/** + * Interface ReplaceEquipmentOutput + * @interface ReplaceEquipmentOutput + * + * @description + * Receipt of an atomic replacement, including receipt replay information. + */ +export interface ReplaceEquipmentOutput { + /** + * Property predecessorEquipmentId + * + * @description + * Retired historical equipment identity. + */ + readonly predecessorEquipmentId: string; + + /** + * Property successorEquipmentId + * + * @description + * Installed successor equipment identity. + */ + readonly successorEquipmentId: string; + + /** + * Property clientOperationId + * + * @description + * Stable operation UUID acknowledged by the server. + */ + readonly clientOperationId: string; + + /** + * Property replayed + * + * @description + * Whether this response reuses an existing receipt. + */ + readonly replayed: boolean; +} diff --git a/src/app/features/organization/features/equipments/models/index.ts b/src/app/features/organization/features/equipments/models/index.ts index 0f513adc2..79f39296e 100644 --- a/src/app/features/organization/features/equipments/models/index.ts +++ b/src/app/features/organization/features/equipments/models/index.ts @@ -3,9 +3,26 @@ export type { EquipmentPlanPosition, EquipmentStatus, EquipmentType, + HistoricalEquipmentType, + EquipmentCriticality, + EquipmentTechnicalProperty, } from './equipment/equipment-output.interface'; export type { CreateEquipmentInput } from './equipment/create-equipment-input.interface'; +export type { + ReplaceEquipmentInput, + ReplaceEquipmentOutput, + ReplacementEquipmentInput, +} from './equipment/replace-equipment-input.interface'; +export type { + EquipmentTypeOutput, + EquipmentTypeOption, + EquipmentFamily, +} from './equipment-type/equipment-type-output.interface'; export type { UpdateEquipmentInput } from './equipment/update-equipment-input.interface'; +export type { EquipmentOpenWorkOutput } from './equipment/equipment-open-work-output.interface'; +export type { EquipmentInspectionSummaryOutput } from './equipment/equipment-inspection-summary-output.interface'; +export type { CreateEquipmentTypeInput } from './equipment-type/create-equipment-type-input.interface'; +export type { UpdateEquipmentTypeInput } from './equipment-type/update-equipment-type-input.interface'; export type { AssignToFacilityInput } from './equipment/assign-to-facility-input.interface'; export type { SetPlanPositionInput } from './equipment/set-plan-position-input.interface'; export type { EquipmentMaintenanceDueStatus } from './equipment/equipment-maintenance-due-status.type'; diff --git a/src/app/features/organization/features/equipments/options/equipment-type-options.constants.ts b/src/app/features/organization/features/equipments/options/equipment-type-options.constants.ts index bc3bcc841..c6aa219ef 100644 --- a/src/app/features/organization/features/equipments/options/equipment-type-options.constants.ts +++ b/src/app/features/organization/features/equipments/options/equipment-type-options.constants.ts @@ -4,11 +4,8 @@ import type { EquipmentType } from '@features/organization/features/equipments/m * Constant EQUIPMENT_TYPE_OPTIONS * * @description - * Localized select options for the fire-safety equipment type picker, - * shared by the create/edit form and the equipment table type filter. The - * backend `EquipmentType` value object is a closed set, so entering the type - * as free text guaranteed a 400 — this constrains the choice to valid values - * and renders a raw enum such as `fire_extinguisher` as "Fire extinguisher". + * Localized historical labels and icons used to decorate the server catalog. + * Production equipment pages obtain their allowed choices from the organization catalog. * * @since 1.0.0 * diff --git a/src/app/features/organization/features/equipments/state/active-equipment/active-equipment.store.ts b/src/app/features/organization/features/equipments/state/active-equipment/active-equipment.store.ts index d3fcbdbd7..5cc6267ee 100644 --- a/src/app/features/organization/features/equipments/state/active-equipment/active-equipment.store.ts +++ b/src/app/features/organization/features/equipments/state/active-equipment/active-equipment.store.ts @@ -3,7 +3,7 @@ import { tapResponse } from '@ngrx/operators'; import { patchState, signalStore, withComputed, withMethods, withState } from '@ngrx/signals'; import { Dispatcher } from '@ngrx/signals/events'; import { rxMethod } from '@ngrx/signals/rxjs-interop'; -import { pipe, switchMap, tap } from 'rxjs'; +import { map, pipe, switchMap, tap } from 'rxjs'; import { errorCallState, idleCallState, @@ -34,6 +34,9 @@ import type { ActiveEquipmentState } from './models'; * @constant INITIAL_ACTIVE_EQUIPMENT_STATE */ const INITIAL_ACTIVE_EQUIPMENT_STATE: ActiveEquipmentState = { + selectedOrganizationId: null, + selectedEquipmentId: null, + selectionGeneration: 0, selectedEquipment: null, getCallState: idleCallState(), } as const; @@ -150,13 +153,42 @@ export const ActiveEquipmentStore = signalStore( * @since 1.0.0 * * @param {EquipmentOutput} equipment - Equipment to mark as active. + * @param {object | undefined} context - Captured selection provenance required for write + * synchronization. * * @returns {void} No return value. */ - setEquipment(equipment: EquipmentOutput): void { - const merged: EquipmentOutput = mergeEquipment(store.selectedEquipment(), equipment); + setEquipment( + equipment: EquipmentOutput, + context?: { + readonly organizationId: string; + readonly equipmentId: string; + readonly selectionGeneration: number; + }, + ): void { + if ( + context && + (store.selectedOrganizationId() !== context.organizationId || + store.selectedEquipmentId() !== context.equipmentId || + store.selectionGeneration() !== context.selectionGeneration || + equipment.id !== context.equipmentId || + (equipment.organizationId !== undefined && + equipment.organizationId !== context.organizationId)) + ) + return; + const organizationId = equipment.organizationId ?? store.selectedOrganizationId(); + const selectionChanged = + store.selectedOrganizationId() !== organizationId || + store.selectedEquipmentId() !== equipment.id; + const merged: EquipmentOutput = mergeEquipment( + selectionChanged ? null : store.selectedEquipment(), + equipment, + ); patchState(store, { + selectedOrganizationId: organizationId, + selectedEquipmentId: equipment.id, + selectionGeneration: store.selectionGeneration() + (selectionChanged ? 1 : 0), selectedEquipment: merged, getCallState: successCallState(merged), }); @@ -184,24 +216,33 @@ export const ActiveEquipmentStore = signalStore( */ resolveEquipment: rxMethod<{ readonly organizationId: string; readonly equipmentId: string }>( pipe( - tap(({ equipmentId }): void => { + tap(({ organizationId, equipmentId }): void => { const current: EquipmentOutput | null = store.selectedEquipment(); + const selectionChanged = + store.selectedOrganizationId() !== organizationId || + store.selectedEquipmentId() !== equipmentId; patchState(store, { - selectedEquipment: current?.id === equipmentId ? current : null, + selectedOrganizationId: organizationId, + selectedEquipmentId: equipmentId, + selectionGeneration: store.selectionGeneration() + (selectionChanged ? 1 : 0), + selectedEquipment: selectionChanged ? null : current, getCallState: pendingCallState(), }); }), - switchMap(({ organizationId, equipmentId }) => + map((params) => ({ ...params, selectionGeneration: store.selectionGeneration() })), + switchMap(({ organizationId, equipmentId, selectionGeneration }) => equipmentService.get(organizationId, equipmentId).pipe( tapResponse({ next: (equipment: EquipmentOutput): void => { + if (store.selectionGeneration() !== selectionGeneration) return; patchState(store, { selectedEquipment: equipment, getCallState: successCallState(equipment), }); }, error: (error: unknown): void => { + if (store.selectionGeneration() !== selectionGeneration) return; const storeError: StoreError = toStoreError(error); patchState(store, { getCallState: errorCallState(storeError) }); dispatcher.dispatch( @@ -228,7 +269,12 @@ export const ActiveEquipmentStore = signalStore( * @returns {void} No return value. */ clearSelectedEquipment(): void { - patchState(store, { selectedEquipment: null }); + patchState(store, { + selectedEquipment: null, + selectedOrganizationId: null, + selectedEquipmentId: null, + selectionGeneration: store.selectionGeneration() + 1, + }); }, /** @@ -244,7 +290,10 @@ export const ActiveEquipmentStore = signalStore( * @returns {void} No return value. */ clear(): void { - patchState(store, INITIAL_ACTIVE_EQUIPMENT_STATE); + patchState(store, { + ...INITIAL_ACTIVE_EQUIPMENT_STATE, + selectionGeneration: store.selectionGeneration() + 1, + }); }, }), ), diff --git a/src/app/features/organization/features/equipments/state/active-equipment/models/state.interface.ts b/src/app/features/organization/features/equipments/state/active-equipment/models/state.interface.ts index d5a7bdca1..70fc18411 100644 --- a/src/app/features/organization/features/equipments/state/active-equipment/models/state.interface.ts +++ b/src/app/features/organization/features/equipments/state/active-equipment/models/state.interface.ts @@ -12,10 +12,44 @@ import type { EquipmentOutput } from '@features/organization/features/equipments * attachments and tags live in the component-scoped {@link EquipmentStore}. * * @version 1.0.0 + * * @author Valentin FORTIN */ export interface ActiveEquipmentState { //#region Properties + /** + * Property selectedOrganizationId + * @readonly + * + * @description + * Organization owning the current selection, including an in-flight route read. + * + * @type {string | null} + */ + readonly selectedOrganizationId: string | null; + + /** + * Property selectedEquipmentId + * @readonly + * + * @description + * Equipment identity selected by the current route. + * + * @type {string | null} + */ + readonly selectedEquipmentId: string | null; + + /** + * Property selectionGeneration + * @readonly + * + * @description + * Monotonic selection fence that rejects writes from an earlier dossier visit. + * + * @type {number} + */ + readonly selectionGeneration: number; + /** * Property selectedEquipment * @readonly @@ -36,7 +70,6 @@ export interface ActiveEquipmentState { * * @description * Call state for fetching the selected equipment. - * * This call state is managed by the resolver and DashboardLayout, not by * the store itself, but it's included here for convenience since it's * tightly coupled to the selected equipment. diff --git a/src/app/features/organization/features/equipments/state/active-equipment/testing/active-equipment.store.spec.ts b/src/app/features/organization/features/equipments/state/active-equipment/testing/active-equipment.store.spec.ts index 0882d49cf..0a1214ad3 100644 --- a/src/app/features/organization/features/equipments/state/active-equipment/testing/active-equipment.store.spec.ts +++ b/src/app/features/organization/features/equipments/state/active-equipment/testing/active-equipment.store.spec.ts @@ -16,7 +16,11 @@ describe('ActiveEquipmentStore', () => { get: ReturnType; }; - const equipment = { id: 'equipment-1', name: 'Generator' } as unknown as EquipmentOutput; + const equipment = { + id: 'equipment-1', + organizationId: 'org-1', + name: 'Generator', + } as unknown as EquipmentOutput; beforeEach(() => { dispatch = vi.fn(); @@ -105,6 +109,19 @@ describe('ActiveEquipmentStore', () => { expect(store.selectedEquipment()).toEqual(other); }); + it('invalidates pending synchronization when selection is cleared', () => { + store.setEquipment(equipment); + const context = { + organizationId: 'org-1', + equipmentId: equipment.id, + selectionGeneration: store.selectionGeneration(), + }; + store.clear(); + store.setEquipment({ ...equipment, name: 'Late write' }, context); + expect(store.selectedEquipment()).toBeNull(); + expect(store.getCallState().status).toBe('idle'); + }); + it('should clear the selected equipment', () => { store.setEquipment(equipment); store.clear(); diff --git a/src/app/features/organization/features/equipments/state/equipment-inspection-summary/equipment-inspection-summary.store.ts b/src/app/features/organization/features/equipments/state/equipment-inspection-summary/equipment-inspection-summary.store.ts new file mode 100644 index 000000000..dc20be64b --- /dev/null +++ b/src/app/features/organization/features/equipments/state/equipment-inspection-summary/equipment-inspection-summary.store.ts @@ -0,0 +1,45 @@ +import { inject } from '@angular/core'; +import { tapResponse } from '@ngrx/operators'; +import { patchState, signalStore, withMethods } from '@ngrx/signals'; +import { rxMethod } from '@ngrx/signals/rxjs-interop'; +import { pipe, switchMap } from 'rxjs'; +import { + setErrorQuery, + setPendingQuery, + setSuccessQuery, + toStoreError, + withQueryState, +} from '@core/request-state'; +import { EquipmentService } from '@features/organization/features/equipments/data-access'; +import type { EquipmentInspectionSummaryOutput } from '@features/organization/features/equipments/models'; + +/** + * Constant EquipmentInspectionSummaryStore + * + * @description + * Reads the exact inspection-owned summary on browser demand, preserving unavailable states. + */ +export const EquipmentInspectionSummaryStore = signalStore( + withQueryState(), + withMethods((store, service = inject(EquipmentService)) => ({ + /** + * Method load + * + * @description + * Reads authorized evidence for the current equipment and cancels stale reads. + */ + load: rxMethod<{ organizationId: string; equipmentId: string }>( + pipe( + switchMap(({ organizationId, equipmentId }) => { + patchState(store, setPendingQuery()); + return service.inspectionSummary(organizationId, equipmentId).pipe( + tapResponse({ + next: (summary) => patchState(store, setSuccessQuery(summary)), + error: (error: unknown) => patchState(store, setErrorQuery(toStoreError(error))), + }), + ); + }), + ), + ), + })), +); diff --git a/src/app/features/organization/features/equipments/state/equipment-inspection-summary/index.ts b/src/app/features/organization/features/equipments/state/equipment-inspection-summary/index.ts new file mode 100644 index 000000000..f7b10f997 --- /dev/null +++ b/src/app/features/organization/features/equipments/state/equipment-inspection-summary/index.ts @@ -0,0 +1 @@ +export { EquipmentInspectionSummaryStore } from './equipment-inspection-summary.store'; diff --git a/src/app/features/organization/features/equipments/state/equipment-open-work/equipment-open-work.store.ts b/src/app/features/organization/features/equipments/state/equipment-open-work/equipment-open-work.store.ts new file mode 100644 index 000000000..dff55f010 --- /dev/null +++ b/src/app/features/organization/features/equipments/state/equipment-open-work/equipment-open-work.store.ts @@ -0,0 +1,45 @@ +import { inject } from '@angular/core'; +import { tapResponse } from '@ngrx/operators'; +import { patchState, signalStore, withMethods } from '@ngrx/signals'; +import { rxMethod } from '@ngrx/signals/rxjs-interop'; +import { pipe, switchMap } from 'rxjs'; +import { + setErrorQuery, + setPendingQuery, + setSuccessQuery, + toStoreError, + withQueryState, +} from '@core/request-state'; +import { EquipmentService } from '@features/organization/features/equipments/data-access'; +import type { EquipmentOpenWorkOutput } from '@features/organization/features/equipments/models'; + +/** + * Constant EquipmentOpenWorkStore + * + * @description + * Secondary authorized open-work lookup; absence of access never means an empty work queue. + */ +export const EquipmentOpenWorkStore = signalStore( + withQueryState(), + withMethods((store, service = inject(EquipmentService)) => ({ + /** + * Method load + * + * @description + * Loads intervention-owned open work for one authorized equipment. + */ + load: rxMethod<{ organizationId: string; equipmentId: string }>( + pipe( + switchMap(({ organizationId, equipmentId }) => { + patchState(store, setPendingQuery()); + return service.openWork(organizationId, equipmentId).pipe( + tapResponse({ + next: (collection) => patchState(store, setSuccessQuery(collection.member)), + error: (error: unknown) => patchState(store, setErrorQuery(toStoreError(error))), + }), + ); + }), + ), + ), + })), +); diff --git a/src/app/features/organization/features/equipments/state/equipment-open-work/index.ts b/src/app/features/organization/features/equipments/state/equipment-open-work/index.ts new file mode 100644 index 000000000..8dc000aa1 --- /dev/null +++ b/src/app/features/organization/features/equipments/state/equipment-open-work/index.ts @@ -0,0 +1 @@ +export { EquipmentOpenWorkStore } from './equipment-open-work.store'; diff --git a/src/app/features/organization/features/equipments/state/equipment-replacement/equipment-replacement.store.ts b/src/app/features/organization/features/equipments/state/equipment-replacement/equipment-replacement.store.ts new file mode 100644 index 000000000..a9fbea242 --- /dev/null +++ b/src/app/features/organization/features/equipments/state/equipment-replacement/equipment-replacement.store.ts @@ -0,0 +1,159 @@ +import { computed, inject } from '@angular/core'; +import { tapResponse } from '@ngrx/operators'; +import { patchState, signalStore, type, withComputed, withMethods, withState } from '@ngrx/signals'; +import { setAllEntities, withEntities } from '@ngrx/signals/entities'; +import { rxMethod } from '@ngrx/signals/rxjs-interop'; +import { exhaustMap, pipe, switchMap } from 'rxjs'; +import { + errorCallState, + idleCallState, + pendingCallState, + successCallState, + toStoreError, +} from '@core/request-state'; +import { EquipmentService } from '@features/organization/features/equipments/data-access'; +import type { + EquipmentOutput, + ReplaceEquipmentInput, +} from '@features/organization/features/equipments/models'; +import { ActiveEquipmentStore } from '../active-equipment'; +import type { EquipmentReplacementState } from './models/equipment-replacement-state.interface'; + +/** + * Constant EquipmentReplacementStore + * + * @description + * Executes atomic replacements and reuses an uncertain command verbatim when retried. + */ +export const EquipmentReplacementStore = signalStore( + withEntities({ entity: type(), collection: 'candidate' }), + withState({ + candidatesCallState: idleCallState(), + replaceCallState: idleCallState(), + command: null, + commandEquipmentId: null, + commandOrganizationId: null, + totalCandidates: 0, + page: 1, + search: '', + }), + withComputed((store) => ({ + pageCount: computed(() => Math.max(1, Math.ceil(store.totalCandidates() / 50))), + })), + withMethods( + (store, service = inject(EquipmentService), active = inject(ActiveEquipmentStore)) => ({ + /** + * Method loadCandidates + * + * @description + * Reads one server page of reserve equipment suitable for replacement. + */ + loadCandidates: rxMethod<{ + organizationId: string; + equipmentId: string; + page?: number; + search?: string; + }>( + pipe( + switchMap(({ organizationId, equipmentId, page = 1, search = '' }) => { + patchState(store, { candidatesCallState: pendingCallState(), page, search }); + return service + .list(organizationId, { + itemsPerPage: 50, + page, + params: { search, status: 'in_stock' }, + }) + .pipe( + tapResponse({ + next: (response) => + patchState( + store, + setAllEntities( + response.member.filter( + (entry) => + entry.id !== equipmentId && + !entry.predecessorEquipmentId && + !entry.successorEquipmentId, + ), + { collection: 'candidate' }, + ), + { + totalCandidates: response.totalItems, + candidatesCallState: successCallState(null), + }, + ), + error: (error: unknown) => + patchState(store, { candidatesCallState: errorCallState(toStoreError(error)) }), + }), + ); + }), + ), + ), + /** + * Method replace + * + * @description + * Submits a new operation, or replays the exact command retained after failure. + */ + replace: rxMethod<{ + organizationId: string; + equipmentId: string; + input: ReplaceEquipmentInput; + }>( + pipe( + exhaustMap(({ organizationId, equipmentId, input }) => { + const command = + store.commandEquipmentId() === equipmentId && + store.commandOrganizationId() === organizationId + ? (store.command() ?? input) + : input; + patchState(store, { + command, + commandEquipmentId: equipmentId, + commandOrganizationId: organizationId, + replaceCallState: pendingCallState(), + }); + return service.replace(organizationId, equipmentId, command).pipe( + tapResponse({ + next: (receipt) => { + const current = active.selectedEquipment(); + if (current?.id === receipt.predecessorEquipmentId) + active.setEquipment({ + ...current, + status: 'decommissioned', + successorEquipmentId: receipt.successorEquipmentId, + }); + patchState(store, { replaceCallState: successCallState(receipt) }); + }, + error: (error: unknown) => + patchState(store, { replaceCallState: errorCallState(toStoreError(error)) }), + }), + ); + }), + ), + ), + /** + * Method reset + * + * @description + * Clears a definitively rejected command, never an uncertain network result. + */ + reset(): void { + const state = store.replaceCallState(); + if (state.status === 'pending' || (state.status === 'error' && state.error?.retryable)) + return; + patchState(store, { command: null, replaceCallState: idleCallState() }); + }, + }), + ), +); + +/** + * Type EquipmentReplacementStoreType + * + * @description + * Instance type for page orchestration. + * + * @type {EquipmentReplacementStoreType} + */ +export type EquipmentReplacementStoreType = InstanceType; diff --git a/src/app/features/organization/features/equipments/state/equipment-replacement/index.ts b/src/app/features/organization/features/equipments/state/equipment-replacement/index.ts new file mode 100644 index 000000000..36e92ae2c --- /dev/null +++ b/src/app/features/organization/features/equipments/state/equipment-replacement/index.ts @@ -0,0 +1,4 @@ +export { + EquipmentReplacementStore, + type EquipmentReplacementStoreType, +} from './equipment-replacement.store'; diff --git a/src/app/features/organization/features/equipments/state/equipment-replacement/models/equipment-replacement-state.interface.ts b/src/app/features/organization/features/equipments/state/equipment-replacement/models/equipment-replacement-state.interface.ts new file mode 100644 index 000000000..f668acbff --- /dev/null +++ b/src/app/features/organization/features/equipments/state/equipment-replacement/models/equipment-replacement-state.interface.ts @@ -0,0 +1,78 @@ +import type { CallState } from '@core/request-state'; +import type { + ReplaceEquipmentInput, + ReplaceEquipmentOutput, +} from '@features/organization/features/equipments/models'; + +/** + * Interface EquipmentReplacementState + * @interface EquipmentReplacementState + * + * @description + * Candidate query state and the immutable command retained for receipt replay. + */ +export interface EquipmentReplacementState { + /** + * Property candidatesCallState + * + * @description + * Independent state of the server candidate query. + */ + readonly candidatesCallState: CallState; + + /** + * Property replaceCallState + * + * @description + * Atomic replacement result or recoverable error. + */ + readonly replaceCallState: CallState; + + /** + * Property commandEquipmentId + * + * @description + * Equipment whose retained operation may be replayed. + */ + readonly commandEquipmentId: string | null; + + /** + * Property commandOrganizationId + * + * @description + * Organization whose retained operation may be replayed. + */ + readonly commandOrganizationId: string | null; + + /** + * Property command + * + * @description + * Exact submitted operation retained until a definitive response. + */ + readonly command: ReplaceEquipmentInput | null; + + /** + * Property totalCandidates + * + * @description + * Server total used for candidate paging. + */ + readonly totalCandidates: number; + + /** + * Property page + * + * @description + * Candidate page displayed in the selector. + */ + readonly page: number; + + /** + * Property search + * + * @description + * Current server search string. + */ + readonly search: string; +} diff --git a/src/app/features/organization/features/equipments/state/equipment-replacement/testing/equipment-replacement.store.spec.ts b/src/app/features/organization/features/equipments/state/equipment-replacement/testing/equipment-replacement.store.spec.ts new file mode 100644 index 000000000..1c44f079e --- /dev/null +++ b/src/app/features/organization/features/equipments/state/equipment-replacement/testing/equipment-replacement.store.spec.ts @@ -0,0 +1,107 @@ +import { HttpErrorResponse } from '@angular/common/http'; +import { provideZonelessChangeDetection, signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { of, Subject, throwError } from 'rxjs'; +import { EquipmentService } from '@features/organization/features/equipments/data-access'; +import type { + EquipmentOutput, + ReplaceEquipmentInput, + ReplaceEquipmentOutput, +} from '@features/organization/features/equipments/models'; +import { ActiveEquipmentStore } from '@features/organization/features/equipments/state'; +import { EquipmentReplacementStore } from '../equipment-replacement.store'; + +const command: ReplaceEquipmentInput = { + clientOperationId: 'd2389ec7-894c-4aab-89d3-e5b3f427b1ff', + successor: { type: 'fire_extinguisher', assetCode: 'EXT-2' }, +}; +const receipt: ReplaceEquipmentOutput = { + clientOperationId: command.clientOperationId, + predecessorEquipmentId: 'old', + successorEquipmentId: 'new', + replayed: true, +}; + +const setup = () => { + const selectedEquipment = signal({ id: 'old', status: 'operational' } as EquipmentOutput); + const setEquipment = vi.fn(); + const replace = vi.fn(); + const list = vi.fn().mockReturnValue(of({ member: [], totalItems: 0 })); + TestBed.configureTestingModule({ + providers: [ + provideZonelessChangeDetection(), + EquipmentReplacementStore, + { provide: EquipmentService, useValue: { replace, list } }, + { provide: ActiveEquipmentStore, useValue: { selectedEquipment, setEquipment } }, + ], + }); + return { + store: TestBed.inject(EquipmentReplacementStore), + replace, + list, + selectedEquipment, + setEquipment, + }; +}; + +describe('EquipmentReplacementStore', () => { + it('replays the exact accepted command after a lost response instead of creating another successor', () => { + const { store, replace, setEquipment } = setup(); + replace + .mockReturnValueOnce(throwError(() => new HttpErrorResponse({ status: 0 }))) + .mockReturnValueOnce(of(receipt)); + store.replace({ organizationId: 'org', equipmentId: 'old', input: command }); + expect(store.replaceCallState().error?.retryable).toBe(true); + store.reset(); + expect(store.command()).toBe(command); + store.replace({ + organizationId: 'org', + equipmentId: 'old', + input: { clientOperationId: 'another', successorEquipmentId: 'different' }, + }); + expect(replace.mock.calls[1][2]).toBe(command); + expect(store.replaceCallState().data).toEqual(receipt); + expect(setEquipment).toHaveBeenCalledWith( + expect.objectContaining({ id: 'old', status: 'decommissioned', successorEquipmentId: 'new' }), + ); + }); + + it('ignores repeated clicks while the accepted transaction is in flight', () => { + const { store, replace } = setup(); + const pending = new Subject(); + replace.mockReturnValue(pending); + store.replace({ organizationId: 'org', equipmentId: 'old', input: command }); + store.replace({ organizationId: 'org', equipmentId: 'old', input: command }); + expect(replace).toHaveBeenCalledOnce(); + expect(store.replaceCallState().status).toBe('pending'); + pending.next(receipt); + pending.complete(); + expect(store.replaceCallState().status).toBe('success'); + }); + + it('allows a corrected command after a definitive refusal', () => { + const { store, replace } = setup(); + replace + .mockReturnValueOnce(throwError(() => new HttpErrorResponse({ status: 409 }))) + .mockReturnValueOnce(of(receipt)); + store.replace({ organizationId: 'org', equipmentId: 'old', input: command }); + store.reset(); + expect(store.command()).toBeNull(); + const corrected: ReplaceEquipmentInput = { + clientOperationId: command.clientOperationId, + successorEquipmentId: 'new', + }; + store.replace({ organizationId: 'org', equipmentId: 'old', input: corrected }); + expect(replace.mock.calls[1][2]).toBe(corrected); + }); + + it('never applies an old equipment receipt to another active dossier', () => { + const { store, replace, selectedEquipment, setEquipment } = setup(); + const pending = new Subject(); + replace.mockReturnValue(pending); + store.replace({ organizationId: 'org', equipmentId: 'old', input: command }); + selectedEquipment.set({ id: 'other' } as EquipmentOutput); + pending.next(receipt); + expect(setEquipment).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/features/organization/features/equipments/state/equipment-type-administration/equipment-type-administration.store.ts b/src/app/features/organization/features/equipments/state/equipment-type-administration/equipment-type-administration.store.ts new file mode 100644 index 000000000..00908f0a9 --- /dev/null +++ b/src/app/features/organization/features/equipments/state/equipment-type-administration/equipment-type-administration.store.ts @@ -0,0 +1,216 @@ +import { inject } from '@angular/core'; +import { tapResponse } from '@ngrx/operators'; +import { patchState, signalStore, withMethods, withState } from '@ngrx/signals'; +import { entityConfig, setAllEntities, upsertEntity, withEntities } from '@ngrx/signals/entities'; +import { Dispatcher } from '@ngrx/signals/events'; +import { rxMethod } from '@ngrx/signals/rxjs-interop'; +import { EMPTY, exhaustMap, pipe, switchMap, type Observable } from 'rxjs'; +import { + errorCallState, + idleCallState, + pendingCallState, + successCallState, + toStoreError, + type CallState, +} from '@core/request-state'; +import { EquipmentTypeService } from '@features/organization/features/equipments/data-access'; +import type { + CreateEquipmentTypeInput, + EquipmentTypeOutput, + UpdateEquipmentTypeInput, +} from '@features/organization/features/equipments/models'; +import { equipmentTypeAdministrationEvents } from './events'; + +/** + * Type EquipmentTypeCommand + * + * @description + * Accepted catalogue command retains its originating organization and reviewed revision. + * + * @type EquipmentTypeCommand + */ +export type EquipmentTypeCommand = + | { + readonly kind: 'create'; + readonly organizationId: string; + readonly input: CreateEquipmentTypeInput; + } + | { + readonly kind: 'update'; + readonly organizationId: string; + readonly value: string; + readonly input: UpdateEquipmentTypeInput; + }; + +/** + * Constant TYPE_CONFIG + * + * @description + * Catalogue entries use permanent codes as entity identities. + */ +const TYPE_CONFIG = entityConfig({ + entity: {} as EquipmentTypeOutput, + collection: 'equipmentType', + selectId: (entry) => entry.value, +}); + +/** + * Constant INITIAL_STATE + * + * @description + * Separate collection and command states distinguish stale reads from rejected edits. + */ +const INITIAL_STATE = { + organizationId: null as string | null, + scopeRevision: 0, + listCallState: idleCallState() as CallState, + writeCallState: idleCallState() as CallState, +}; + +/** + * Constant EquipmentTypeAdministrationStore + * + * @description + * Page-owned catalogue administration cancels obsolete reads and preserves accepted writes. + */ +export const EquipmentTypeAdministrationStore = signalStore( + withEntities(TYPE_CONFIG), + withState(INITIAL_STATE), + withMethods((store, service = inject(EquipmentTypeService)) => ({ + /** + * Method load + * @method load + * + * @description + * Loads every entry and clears data immediately when the organization changes. + * + * @access public + * @since unreleased + * + * @param {string | null} organizationId - Organization to read, or null to clear. + * + * @returns {void} + */ + load: rxMethod( + pipe( + switchMap((organizationId) => { + if (organizationId !== store.organizationId()) { + const scopeRevision = store.scopeRevision() + 1; + patchState( + store, + INITIAL_STATE, + { scopeRevision }, + setAllEntities([] as EquipmentTypeOutput[], TYPE_CONFIG), + ); + } + if (!organizationId) return EMPTY; + patchState(store, { organizationId, listCallState: pendingCallState() }); + return service.listAll(organizationId).pipe( + tapResponse({ + next: (entries) => { + const reconciled = new Map(entries.map((entry) => [entry.value, entry])); + for (const current of store.equipmentTypeEntities()) { + const incoming = reconciled.get(current.value); + if (!incoming || current.revision > incoming.revision) + reconciled.set(current.value, current); + } + patchState(store, setAllEntities([...reconciled.values()], TYPE_CONFIG), { + listCallState: successCallState(null), + }); + }, + error: (error: unknown) => + patchState(store, { + listCallState: errorCallState(toStoreError(error)), + }), + }), + ); + }), + ), + ), + + /** + * Method clearWrite + * @method clearWrite + * + * @description + * Clears a previous rejection only when the operator opens a new editor. + * + * @access public + * @since unreleased + * + * @returns {void} + */ + clearWrite(): void { + if (store.writeCallState().status !== 'pending') + patchState(store, { writeCallState: idleCallState() }); + }, + })), + withMethods((store, service = inject(EquipmentTypeService), dispatcher = inject(Dispatcher)) => ({ + /** + * Method save + * @method save + * + * @description + * Executes one accepted command at a time; conflicts retain the reviewed revision and draft. + * + * @access public + * @since unreleased + * + * @param {EquipmentTypeCommand} command - Organization and explicit write intent. + * + * @returns {void} + */ + save: rxMethod( + pipe( + exhaustMap((command) => { + if (command.organizationId !== store.organizationId()) return EMPTY; + const scopeRevision = store.scopeRevision(); + patchState(store, { writeCallState: pendingCallState() }); + const request: Observable = + command.kind === 'create' + ? service.create(command.organizationId, command.input) + : service.update(command.organizationId, command.value, command.input); + return request.pipe( + tapResponse({ + next: (entry) => { + if ( + store.organizationId() === command.organizationId && + store.scopeRevision() === scopeRevision + ) { + patchState(store, upsertEntity(entry, TYPE_CONFIG), { + writeCallState: successCallState(entry), + }); + dispatcher.dispatch( + equipmentTypeAdministrationEvents.saved({ + organizationId: command.organizationId, + value: entry.value, + }), + ); + } + }, + error: (error: unknown) => { + if ( + store.organizationId() === command.organizationId && + store.scopeRevision() === scopeRevision + ) + patchState(store, { writeCallState: errorCallState(toStoreError(error)) }); + }, + }), + ); + }), + ), + ), + })), +); + +/** + * Type EquipmentTypeAdministrationStoreType + * + * @description + * Page-scoped injectable catalogue administrator. + * + * @type EquipmentTypeAdministrationStoreType + */ +export type EquipmentTypeAdministrationStoreType = InstanceType< + typeof EquipmentTypeAdministrationStore +>; diff --git a/src/app/features/organization/features/equipments/state/equipment-type-administration/events.ts b/src/app/features/organization/features/equipments/state/equipment-type-administration/events.ts new file mode 100644 index 000000000..f04ff163d --- /dev/null +++ b/src/app/features/organization/features/equipments/state/equipment-type-administration/events.ts @@ -0,0 +1,13 @@ +import { type } from '@ngrx/signals'; +import { eventGroup } from '@ngrx/signals/events'; + +/** + * Constant equipmentTypeAdministrationEvents + * + * @description + * Confirmed catalogue writes dismiss editors without discarding rejected drafts. + */ +export const equipmentTypeAdministrationEvents = eventGroup({ + source: 'Equipment Type Administration', + events: { saved: type<{ readonly organizationId: string; readonly value: string }>() }, +}); diff --git a/src/app/features/organization/features/equipments/state/equipment-type-administration/index.ts b/src/app/features/organization/features/equipments/state/equipment-type-administration/index.ts new file mode 100644 index 000000000..0ab21f413 --- /dev/null +++ b/src/app/features/organization/features/equipments/state/equipment-type-administration/index.ts @@ -0,0 +1,6 @@ +export { + EquipmentTypeAdministrationStore, + type EquipmentTypeAdministrationStoreType, + type EquipmentTypeCommand, +} from './equipment-type-administration.store'; +export { equipmentTypeAdministrationEvents } from './events'; diff --git a/src/app/features/organization/features/equipments/state/equipment-type-administration/testing/equipment-type-administration.store.spec.ts b/src/app/features/organization/features/equipments/state/equipment-type-administration/testing/equipment-type-administration.store.spec.ts new file mode 100644 index 000000000..9dd78a801 --- /dev/null +++ b/src/app/features/organization/features/equipments/state/equipment-type-administration/testing/equipment-type-administration.store.spec.ts @@ -0,0 +1,164 @@ +import { HttpErrorResponse } from '@angular/common/http'; +import { TestBed } from '@angular/core/testing'; +import { Dispatcher } from '@ngrx/signals/events'; +import { of, Subject } from 'rxjs'; +import { EquipmentTypeService } from '@features/organization/features/equipments/data-access'; +import type { EquipmentTypeOutput } from '@features/organization/features/equipments/models'; +import { + EquipmentTypeAdministrationStore, + type EquipmentTypeAdministrationStoreType, +} from '../equipment-type-administration.store'; +import { equipmentTypeAdministrationEvents } from '../events'; + +describe('EquipmentTypeAdministrationStore', () => { + let store: EquipmentTypeAdministrationStoreType; + const entry: EquipmentTypeOutput = { + '@id': '/api/organizations/org-1/equipment-types/blanket', + '@type': 'EquipmentType', + value: 'blanket', + label: 'Blanket', + family: 'fire', + archived: false, + revision: 3, + }; + let service: { + listAll: ReturnType; + create: ReturnType; + update: ReturnType; + }; + let dispatcher: { dispatch: ReturnType }; + + beforeEach(() => { + service = { listAll: vi.fn().mockReturnValue(of([entry])), create: vi.fn(), update: vi.fn() }; + dispatcher = { dispatch: vi.fn() }; + TestBed.configureTestingModule({ + providers: [ + EquipmentTypeAdministrationStore, + { provide: EquipmentTypeService, useValue: service }, + { provide: Dispatcher, useValue: dispatcher }, + ], + }); + store = TestBed.inject(EquipmentTypeAdministrationStore); + store.load('org-1'); + }); + + it('cancels obsolete reads and clears old organization descriptors immediately', () => { + const oldRead = new Subject(); + const newRead = new Subject(); + service.listAll.mockReturnValueOnce(oldRead).mockReturnValueOnce(newRead); + store.load('org-1'); + store.load('org-2'); + expect(store.equipmentTypeEntities()).toEqual([]); + oldRead.next([entry]); + expect(store.equipmentTypeEntities()).toEqual([]); + newRead.next([{ ...entry, value: 'pump' }]); + expect(store.equipmentTypeEntities().map((type) => type.value)).toEqual(['pump']); + expect(store.listCallState().status).toBe('success'); + }); + + it('does not cancel or duplicate an accepted write on repeated submit', () => { + const response = new Subject(); + service.update.mockReturnValue(response); + const command = { + kind: 'update' as const, + organizationId: 'org-1', + value: entry.value, + input: { revision: 3, label: 'New label' }, + }; + store.save(command); + store.save(command); + store.clearWrite(); + expect(service.update).toHaveBeenCalledTimes(1); + expect(store.writeCallState().status).toBe('pending'); + response.next({ ...entry, label: 'New label', revision: 4 }); + response.complete(); + expect(store.equipmentTypeEntities()[0]?.label).toBe('New label'); + expect(store.writeCallState().status).toBe('success'); + expect(dispatcher.dispatch).toHaveBeenCalledWith( + equipmentTypeAdministrationEvents.saved({ organizationId: 'org-1', value: entry.value }), + ); + }); + + it('retains old descriptor and conflict detail without advancing its reviewed revision', () => { + const response = new Subject(); + service.update.mockReturnValue(response); + store.save({ + kind: 'update', + organizationId: 'org-1', + value: entry.value, + input: { revision: 3, archived: true }, + }); + response.error(new HttpErrorResponse({ status: 409, error: { detail: 'Revision changed.' } })); + expect(store.writeCallState().status).toBe('error'); + expect(store.writeCallState().error).toMatchObject({ code: 409, message: 'Revision changed.' }); + expect(store.equipmentTypeEntities()).toEqual([entry]); + expect(dispatcher.dispatch).not.toHaveBeenCalled(); + expect(service.update).toHaveBeenCalledWith('org-1', entry.value, { + revision: 3, + archived: true, + }); + }); + + it('does not leak late command feedback after leaving and revisiting its organization', () => { + const response = new Subject(); + service.update.mockReturnValue(response); + store.save({ + kind: 'update', + organizationId: 'org-1', + value: entry.value, + input: { revision: 3, label: 'Stale session' }, + }); + store.load('org-2'); + store.load('org-1'); + response.next({ ...entry, label: 'Stale session', revision: 4 }); + response.complete(); + expect(store.equipmentTypeEntities()).toEqual([entry]); + expect(store.writeCallState().status).toBe('idle'); + expect(dispatcher.dispatch).not.toHaveBeenCalled(); + }); + + it('does not regress a confirmed descriptor when an older list response arrives later', () => { + const response = new Subject(); + service.listAll.mockReturnValue(response); + store.load('org-1'); + service.update.mockReturnValue(of({ ...entry, archived: true, revision: 4 })); + store.save({ + kind: 'update', + organizationId: 'org-1', + value: entry.value, + input: { revision: 3, archived: true }, + }); + response.next([entry]); + expect(store.equipmentTypeEntities()[0]).toMatchObject({ archived: true, revision: 4 }); + }); + + it('keeps archives in the catalogue and resets only feedback for a new editor', () => { + service.update.mockReturnValue(of({ ...entry, archived: true, revision: 4 })); + store.save({ + kind: 'update', + organizationId: 'org-1', + value: entry.value, + input: { revision: 3, archived: true }, + }); + expect(store.equipmentTypeEntities()[0]?.archived).toBe(true); + store.clearWrite(); + expect(store.writeCallState().status).toBe('idle'); + expect(store.equipmentTypeEntities()[0]?.archived).toBe(true); + }); + + it('adds a confirmed custom type and refuses commands for another organization', () => { + service.create.mockReturnValue(of({ ...entry, value: 'camera', family: 'safety' })); + store.save({ + kind: 'create', + organizationId: 'org-2', + input: { value: 'camera', label: 'Camera', family: 'safety' }, + }); + expect(service.create).not.toHaveBeenCalled(); + store.save({ + kind: 'create', + organizationId: 'org-1', + input: { value: 'camera', label: 'Camera', family: 'safety' }, + }); + expect(store.equipmentTypeEntities().map((type) => type.value)).toEqual(['blanket', 'camera']); + }); +}); diff --git a/src/app/features/organization/features/equipments/state/equipment-type-catalog/equipment-type-catalog.store.ts b/src/app/features/organization/features/equipments/state/equipment-type-catalog/equipment-type-catalog.store.ts new file mode 100644 index 000000000..5a20d9e52 --- /dev/null +++ b/src/app/features/organization/features/equipments/state/equipment-type-catalog/equipment-type-catalog.store.ts @@ -0,0 +1,122 @@ +import { isPlatformBrowser } from '@angular/common'; +import { computed, inject, PLATFORM_ID } from '@angular/core'; +import { tapResponse } from '@ngrx/operators'; +import { patchState, signalStore, withComputed, withMethods, withState } from '@ngrx/signals'; +import { entityConfig, setAllEntities, withEntities } from '@ngrx/signals/entities'; +import { rxMethod } from '@ngrx/signals/rxjs-interop'; +import { EMPTY, pipe, switchMap } from 'rxjs'; +import { + errorCallState, + idleCallState, + pendingCallState, + successCallState, + toStoreError, + type CallState, +} from '@core/request-state'; +import { EquipmentTypeService } from '@features/organization/features/equipments/data-access'; +import type { EquipmentTypeOutput } from '@features/organization/features/equipments/models'; +import { equipmentTypeOption } from '@features/organization/features/equipments/utils'; + +/** + * Constant TYPE_CONFIG + * + * @description + * Server catalog identity is its stable value, rather than an equipment UUID. + */ +const TYPE_CONFIG = entityConfig({ + entity: {} as EquipmentTypeOutput, + collection: 'equipmentType', + selectId: (entry) => entry.value, +}); + +/** + * Constant EquipmentTypeCatalogStore + * + * @description + * Organization-scoped catalog source shared by equipment, site and onboarding pickers. + * Each page provides its own instance; organization changes clear previously visible entries. + */ +export const EquipmentTypeCatalogStore = signalStore( + withEntities(TYPE_CONFIG), + withState({ organizationId: null as string | null, loadCallState: idleCallState() as CallState }), + withComputed((store) => ({ + options: computed(() => store.equipmentTypeEntities().map(equipmentTypeOption)), + activeOptions: computed(() => + store + .equipmentTypeEntities() + .filter((entry) => !entry.archived) + .map(equipmentTypeOption), + ), + })), + withMethods( + (store, service = inject(EquipmentTypeService), platformId = inject(PLATFORM_ID)) => ({ + /** + * Method load + * + * @description + * Loads catalog entries without leaking an earlier organization's choices. + */ + load: rxMethod( + pipe( + switchMap((organizationId) => { + if (organizationId === null) { + patchState(store, setAllEntities([] as EquipmentTypeOutput[], TYPE_CONFIG), { + organizationId: null, + loadCallState: idleCallState(), + }); + return EMPTY; + } + if (!isPlatformBrowser(platformId)) return EMPTY; + if (store.organizationId() !== organizationId) + patchState(store, setAllEntities([] as EquipmentTypeOutput[], TYPE_CONFIG)); + patchState(store, { organizationId, loadCallState: pendingCallState() }); + return service.listAll(organizationId).pipe( + tapResponse({ + next: (entries) => + patchState(store, setAllEntities([...entries], TYPE_CONFIG), { + loadCallState: successCallState(null), + }), + error: (error: unknown) => + patchState(store, { loadCallState: errorCallState(toStoreError(error)) }), + }), + ); + }), + ), + ), + }), + ), + withMethods((store) => ({ + /** + * Method clear + * + * @description + * Cancels pending reads and removes catalog data when organization or access changes. + */ + clear(): void { + store.load(null); + }, + /** + * Method seed + * + * @description + * Cancels pending reads before hydrating an actor-authorized organization snapshot. + */ + seed(organizationId: string, entries: readonly EquipmentTypeOutput[]): void { + store.load(null); + patchState(store, setAllEntities([...entries], TYPE_CONFIG), { + organizationId, + loadCallState: successCallState(null), + }); + }, + })), +); + +/** + * Type EquipmentTypeCatalogStoreType + * + * @description + * Injectable instance of the catalog store. + * + * @type {EquipmentTypeCatalogStoreType} + */ +export type EquipmentTypeCatalogStoreType = InstanceType; diff --git a/src/app/features/organization/features/equipments/state/equipment-type-catalog/index.ts b/src/app/features/organization/features/equipments/state/equipment-type-catalog/index.ts new file mode 100644 index 000000000..405cf8b38 --- /dev/null +++ b/src/app/features/organization/features/equipments/state/equipment-type-catalog/index.ts @@ -0,0 +1,4 @@ +export { + EquipmentTypeCatalogStore, + type EquipmentTypeCatalogStoreType, +} from './equipment-type-catalog.store'; diff --git a/src/app/features/organization/features/equipments/state/equipment-type-catalog/testing/equipment-type-catalog.store.spec.ts b/src/app/features/organization/features/equipments/state/equipment-type-catalog/testing/equipment-type-catalog.store.spec.ts new file mode 100644 index 000000000..abc9a3ffc --- /dev/null +++ b/src/app/features/organization/features/equipments/state/equipment-type-catalog/testing/equipment-type-catalog.store.spec.ts @@ -0,0 +1,104 @@ +import { HttpErrorResponse } from '@angular/common/http'; +import { PLATFORM_ID, provideZonelessChangeDetection } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { of, Subject, throwError } from 'rxjs'; +import { EquipmentTypeService } from '@features/organization/features/equipments/data-access'; +import type { EquipmentTypeOutput } from '@features/organization/features/equipments/models'; +import { EquipmentTypeCatalogStore } from '../equipment-type-catalog.store'; + +const entry = ( + value: string, + overrides: Partial = {}, +): EquipmentTypeOutput => ({ + '@id': `/api/types/${value}`, + '@type': 'EquipmentType', + value, + label: value, + family: 'fire', + archived: false, + revision: 1, + ...overrides, +}); + +describe('EquipmentTypeCatalogStore', () => { + const setup = (platform = 'browser') => { + const listAll = vi + .fn() + .mockReturnValue(of([entry('fire_extinguisher'), entry('foam_monitor')])); + TestBed.configureTestingModule({ + providers: [ + provideZonelessChangeDetection(), + EquipmentTypeCatalogStore, + { provide: PLATFORM_ID, useValue: platform }, + { provide: EquipmentTypeService, useValue: { listAll } }, + ], + }); + return { store: TestBed.inject(EquipmentTypeCatalogStore), listAll }; + }; + + it('preserves custom codes, localized historical labels and archived read choices', () => { + const { store, listAll } = setup(); + listAll.mockReturnValue( + of([ + entry('fire_extinguisher'), + entry('foam_monitor', { archived: true }), + entry('camera', { label: 'Reception camera', revision: 2 }), + ]), + ); + store.load('org-a'); + expect(store.options().map((option) => option.label)).toEqual([ + 'Fire extinguisher', + 'foam_monitor', + 'Reception camera', + ]); + expect(store.options()[1].icon).toBe('lucideBox'); + expect(store.activeOptions().map((option) => option.value)).toEqual([ + 'fire_extinguisher', + 'camera', + ]); + }); + + it('cancels stale organization reads and clears unauthorized previous choices', () => { + const { store, listAll } = setup(); + const first = new Subject(); + const second = new Subject(); + listAll.mockReturnValueOnce(first).mockReturnValueOnce(second); + store.seed('org-before', [entry('private_type')]); + store.load('org-a'); + expect(store.options()).toEqual([]); + store.load('org-b'); + first.next([entry('wrong_type')]); + second.next([entry('allowed_type')]); + expect(store.organizationId()).toBe('org-b'); + expect(store.options().map((option) => option.value)).toEqual(['allowed_type']); + }); + + it('retains the same organization catalog on refresh failure and supports retry', () => { + const { store, listAll } = setup(); + store.load('org-a'); + listAll.mockReturnValueOnce(throwError(() => new HttpErrorResponse({ status: 503 }))); + store.load('org-a'); + expect(store.loadCallState().status).toBe('error'); + expect(store.options()).toHaveLength(2); + store.load('org-a'); + expect(store.loadCallState().status).toBe('success'); + }); + + it('makes no secondary catalog request during server rendering', () => { + const { store, listAll } = setup('server'); + store.load('org-a'); + expect(listAll).not.toHaveBeenCalled(); + }); + + it('cancels an in-flight catalog read when access is revoked', () => { + const { store, listAll } = setup(); + const late = new Subject(); + listAll.mockReturnValue(late); + store.load('org-a'); + store.clear(); + late.next([entry('revoked')]); + expect(store.organizationId()).toBeNull(); + expect(store.options()).toEqual([]); + expect(store.loadCallState().status).toBe('idle'); + }); +}); diff --git a/src/app/features/organization/features/equipments/state/equipment/equipment.store.ts b/src/app/features/organization/features/equipments/state/equipment/equipment.store.ts index cf72266cd..a34cdcfec 100644 --- a/src/app/features/organization/features/equipments/state/equipment/equipment.store.ts +++ b/src/app/features/organization/features/equipments/state/equipment/equipment.store.ts @@ -11,7 +11,7 @@ import { } from '@ngrx/signals/entities'; import { Dispatcher } from '@ngrx/signals/events'; import { rxMethod } from '@ngrx/signals/rxjs-interop'; -import { exhaustMap, pipe, switchMap, tap } from 'rxjs'; +import { exhaustMap, map, pipe, switchMap, tap } from 'rxjs'; import type { RequestOptions } from '@core/api'; import type { HydraCollection } from '@core/api/models'; import { @@ -586,31 +586,47 @@ export const EquipmentStore = signalStore( tap((): void => { patchState(store, { updateCallState: pendingCallState() }); }), - exhaustMap(({ organizationId, equipmentId, input }) => - equipmentService.update(organizationId, equipmentId, input).pipe( - tapResponse({ - next: (equipment: EquipmentOutput): void => { - const merged: EquipmentOutput = mergeEquipment( - store.equipmentEntityMap()[equipment.id] ?? null, - equipment, - ); + map((params) => ({ + ...params, + selectionGeneration: activeEquipmentStore.selectionGeneration(), + knownEquipment: + store.equipmentEntityMap()[params.equipmentId] ?? + (activeEquipmentStore.selectedEquipment()?.id === params.equipmentId && + activeEquipmentStore.selectedOrganizationId() === params.organizationId + ? activeEquipmentStore.selectedEquipment() + : null), + })), + exhaustMap( + ({ organizationId, equipmentId, input, selectionGeneration, knownEquipment }) => + equipmentService.update(organizationId, equipmentId, input).pipe( + tapResponse({ + next: (equipment: EquipmentOutput): void => { + const merged: EquipmentOutput = mergeEquipment( + store.equipmentEntityMap()[equipment.id] ?? knownEquipment, + equipment, + input, + ); - patchState(store, setEntity(merged, { collection: 'equipment' }), { - updateCallState: successCallState(merged), - }); - activeEquipmentStore.setEquipment(merged); - }, - error: (error: unknown): void => { - const storeError: StoreError = toStoreError(error); - patchState(store, { updateCallState: errorCallState(storeError) }); - dispatcher.dispatch( - equipmentStoreEvents.updateFailed( - toStoreFailureEventPayload(storeError, 'Failed to update equipment'), - ), - ); - }, - }), - ), + patchState(store, setEntity(merged, { collection: 'equipment' }), { + updateCallState: successCallState(merged), + }); + activeEquipmentStore.setEquipment(merged, { + organizationId, + equipmentId, + selectionGeneration, + }); + }, + error: (error: unknown): void => { + const storeError: StoreError = toStoreError(error); + patchState(store, { updateCallState: errorCallState(storeError) }); + dispatcher.dispatch( + equipmentStoreEvents.updateFailed( + toStoreFailureEventPayload(storeError, 'Failed to update equipment'), + ), + ); + }, + }), + ), ), ), ), @@ -643,34 +659,49 @@ export const EquipmentStore = signalStore( tap((): void => { patchState(store, { assignToFacilityCallState: pendingCallState() }); }), - exhaustMap(({ organizationId, equipmentId, input }) => - equipmentService.assignToFacility(organizationId, equipmentId, input).pipe( - tapResponse({ - next: (equipment: EquipmentOutput): void => { - const merged: EquipmentOutput = mergeEquipment( - store.equipmentEntityMap()[equipment.id] ?? null, - equipment, - ); + map((params) => ({ + ...params, + selectionGeneration: activeEquipmentStore.selectionGeneration(), + knownEquipment: + store.equipmentEntityMap()[params.equipmentId] ?? + (activeEquipmentStore.selectedEquipment()?.id === params.equipmentId && + activeEquipmentStore.selectedOrganizationId() === params.organizationId + ? activeEquipmentStore.selectedEquipment() + : null), + })), + exhaustMap( + ({ organizationId, equipmentId, input, selectionGeneration, knownEquipment }) => + equipmentService.assignToFacility(organizationId, equipmentId, input).pipe( + tapResponse({ + next: (equipment: EquipmentOutput): void => { + const merged: EquipmentOutput = mergeEquipment( + store.equipmentEntityMap()[equipment.id] ?? knownEquipment, + equipment, + ); - patchState(store, setEntity(merged, { collection: 'equipment' }), { - assignToFacilityCallState: successCallState(merged), - }); - activeEquipmentStore.setEquipment(merged); - }, - error: (error: unknown): void => { - const storeError: StoreError = toStoreError(error); - patchState(store, { assignToFacilityCallState: errorCallState(storeError) }); - dispatcher.dispatch( - equipmentStoreEvents.assignToFacilityFailed( - toStoreFailureEventPayload( - storeError, - 'Failed to assign equipment to facility', + patchState(store, setEntity(merged, { collection: 'equipment' }), { + assignToFacilityCallState: successCallState(merged), + }); + activeEquipmentStore.setEquipment(merged, { + organizationId, + equipmentId, + selectionGeneration, + }); + }, + error: (error: unknown): void => { + const storeError: StoreError = toStoreError(error); + patchState(store, { assignToFacilityCallState: errorCallState(storeError) }); + dispatcher.dispatch( + equipmentStoreEvents.assignToFacilityFailed( + toStoreFailureEventPayload( + storeError, + 'Failed to assign equipment to facility', + ), ), - ), - ); - }, - }), - ), + ); + }, + }), + ), ), ), ), @@ -696,13 +727,23 @@ export const EquipmentStore = signalStore( tap((): void => { patchState(store, { unassignFromFacilityCallState: pendingCallState() }); }), - exhaustMap(({ organizationId, equipmentId }) => + map((params) => ({ + ...params, + selectionGeneration: activeEquipmentStore.selectionGeneration(), + knownEquipment: + store.equipmentEntityMap()[params.equipmentId] ?? + (activeEquipmentStore.selectedEquipment()?.id === params.equipmentId && + activeEquipmentStore.selectedOrganizationId() === params.organizationId + ? activeEquipmentStore.selectedEquipment() + : null), + })), + exhaustMap(({ organizationId, equipmentId, selectionGeneration, knownEquipment }) => equipmentService.unassignFromFacility(organizationId, equipmentId).pipe( tapResponse({ next: (equipment: EquipmentOutput): void => { const merged: EquipmentOutput = { ...mergeEquipment( - store.equipmentEntityMap()[equipment.id] ?? null, + store.equipmentEntityMap()[equipment.id] ?? knownEquipment, equipment, ), facilityId: equipment.facilityId ?? null, @@ -712,7 +753,11 @@ export const EquipmentStore = signalStore( patchState(store, setEntity(merged, { collection: 'equipment' }), { unassignFromFacilityCallState: successCallState(merged), }); - activeEquipmentStore.setEquipment(merged); + activeEquipmentStore.setEquipment(merged, { + organizationId, + equipmentId, + selectionGeneration, + }); }, error: (error: unknown): void => { const storeError: StoreError = toStoreError(error); @@ -752,19 +797,33 @@ export const EquipmentStore = signalStore( tap((): void => { patchState(store, { commissionCallState: pendingCallState() }); }), - exhaustMap(({ organizationId, equipmentId }) => + map((params) => ({ + ...params, + selectionGeneration: activeEquipmentStore.selectionGeneration(), + knownEquipment: + store.equipmentEntityMap()[params.equipmentId] ?? + (activeEquipmentStore.selectedEquipment()?.id === params.equipmentId && + activeEquipmentStore.selectedOrganizationId() === params.organizationId + ? activeEquipmentStore.selectedEquipment() + : null), + })), + exhaustMap(({ organizationId, equipmentId, selectionGeneration, knownEquipment }) => equipmentService.commission(organizationId, equipmentId).pipe( tapResponse({ next: (equipment: EquipmentOutput): void => { const merged: EquipmentOutput = mergeEquipment( - store.equipmentEntityMap()[equipment.id] ?? null, + store.equipmentEntityMap()[equipment.id] ?? knownEquipment, equipment, ); patchState(store, setEntity(merged, { collection: 'equipment' }), { commissionCallState: successCallState(merged), }); - activeEquipmentStore.setEquipment(merged); + activeEquipmentStore.setEquipment(merged, { + organizationId, + equipmentId, + selectionGeneration, + }); }, error: (error: unknown): void => { const storeError: StoreError = toStoreError(error); @@ -799,19 +858,33 @@ export const EquipmentStore = signalStore( tap((): void => { patchState(store, { decommissionCallState: pendingCallState() }); }), - exhaustMap(({ organizationId, equipmentId }) => + map((params) => ({ + ...params, + selectionGeneration: activeEquipmentStore.selectionGeneration(), + knownEquipment: + store.equipmentEntityMap()[params.equipmentId] ?? + (activeEquipmentStore.selectedEquipment()?.id === params.equipmentId && + activeEquipmentStore.selectedOrganizationId() === params.organizationId + ? activeEquipmentStore.selectedEquipment() + : null), + })), + exhaustMap(({ organizationId, equipmentId, selectionGeneration, knownEquipment }) => equipmentService.decommission(organizationId, equipmentId).pipe( tapResponse({ next: (equipment: EquipmentOutput): void => { const merged: EquipmentOutput = mergeEquipment( - store.equipmentEntityMap()[equipment.id] ?? null, + store.equipmentEntityMap()[equipment.id] ?? knownEquipment, equipment, ); patchState(store, setEntity(merged, { collection: 'equipment' }), { decommissionCallState: successCallState(merged), }); - activeEquipmentStore.setEquipment(merged); + activeEquipmentStore.setEquipment(merged, { + organizationId, + equipmentId, + selectionGeneration, + }); }, error: (error: unknown): void => { const storeError: StoreError = toStoreError(error); @@ -902,19 +975,33 @@ export const EquipmentStore = signalStore( tap((): void => { patchState(store, { maintenanceCallState: pendingCallState() }); }), - exhaustMap(({ organizationId, equipmentId }) => + map((params) => ({ + ...params, + selectionGeneration: activeEquipmentStore.selectionGeneration(), + knownEquipment: + store.equipmentEntityMap()[params.equipmentId] ?? + (activeEquipmentStore.selectedEquipment()?.id === params.equipmentId && + activeEquipmentStore.selectedOrganizationId() === params.organizationId + ? activeEquipmentStore.selectedEquipment() + : null), + })), + exhaustMap(({ organizationId, equipmentId, selectionGeneration, knownEquipment }) => equipmentService.maintenance(organizationId, equipmentId).pipe( tapResponse({ next: (equipment: EquipmentOutput): void => { const merged: EquipmentOutput = mergeEquipment( - store.equipmentEntityMap()[equipment.id] ?? null, + store.equipmentEntityMap()[equipment.id] ?? knownEquipment, equipment, ); patchState(store, setEntity(merged, { collection: 'equipment' }), { maintenanceCallState: successCallState(merged), }); - activeEquipmentStore.setEquipment(merged); + activeEquipmentStore.setEquipment(merged, { + organizationId, + equipmentId, + selectionGeneration, + }); }, error: (error: unknown): void => { const storeError: StoreError = toStoreError(error); @@ -1147,7 +1234,11 @@ export const EquipmentStore = signalStore( tap((): void => { patchState(store, { addTagCallState: pendingCallState() }); }), - exhaustMap(({ organizationId, equipmentId, input }) => + map((params) => ({ + ...params, + selectionGeneration: activeEquipmentStore.selectionGeneration(), + })), + exhaustMap(({ organizationId, equipmentId, input, selectionGeneration }) => equipmentService.addTag(organizationId, equipmentId, input).pipe( tapResponse({ next: (tag: EquipmentTagOutput): void => { @@ -1160,10 +1251,13 @@ export const EquipmentStore = signalStore( activeEquipment?.id === equipmentId && !activeEquipment.tags.some((activeTag) => activeTag.id === tag.id) ) { - activeEquipmentStore.setEquipment({ - ...activeEquipment, - tags: [...activeEquipment.tags, tag], - }); + activeEquipmentStore.setEquipment( + { + ...activeEquipment, + tags: [...activeEquipment.tags, tag], + }, + { organizationId, equipmentId, selectionGeneration }, + ); } }, error: (error: unknown): void => { @@ -1198,7 +1292,11 @@ export const EquipmentStore = signalStore( tap((): void => { patchState(store, { removeTagCallState: pendingCallState() }); }), - exhaustMap(({ organizationId, equipmentId, tagId }) => + map((params) => ({ + ...params, + selectionGeneration: activeEquipmentStore.selectionGeneration(), + })), + exhaustMap(({ organizationId, equipmentId, tagId, selectionGeneration }) => equipmentService.removeTag(organizationId, equipmentId, tagId).pipe( tapResponse({ next: (): void => { @@ -1208,10 +1306,13 @@ export const EquipmentStore = signalStore( }); const activeEquipment = activeEquipmentStore.selectedEquipment(); if (activeEquipment?.id === equipmentId) { - activeEquipmentStore.setEquipment({ - ...activeEquipment, - tags: activeEquipment.tags.filter((tag) => tag.id !== tagId), - }); + activeEquipmentStore.setEquipment( + { + ...activeEquipment, + tags: activeEquipment.tags.filter((tag) => tag.id !== tagId), + }, + { organizationId, equipmentId, selectionGeneration }, + ); } }, error: (error: unknown): void => { diff --git a/src/app/features/organization/features/equipments/state/equipment/testing/equipment.store.spec.ts b/src/app/features/organization/features/equipments/state/equipment/testing/equipment.store.spec.ts index f8f3ff144..f1285904c 100644 --- a/src/app/features/organization/features/equipments/state/equipment/testing/equipment.store.spec.ts +++ b/src/app/features/organization/features/equipments/state/equipment/testing/equipment.store.spec.ts @@ -20,6 +20,7 @@ const flushEffects = async (): Promise => { describe('EquipmentStore', () => { let store: EquipmentStore; let mockEquipmentService: { + get: ReturnType; list: ReturnType; listMaintenanceLogs: ReturnType; remove: ReturnType; @@ -42,6 +43,8 @@ describe('EquipmentStore', () => { selectedEquipment: ReturnType>; isLoadingEquipment: ReturnType>; setEquipment: ReturnType; + selectedOrganizationId: ReturnType>; + selectionGeneration: ReturnType>; }; const equipment = { id: 'equipment-1', name: 'Generator' } as unknown as EquipmentOutput; @@ -66,6 +69,7 @@ describe('EquipmentStore', () => { beforeEach(() => { mockEquipmentService = { + get: vi.fn(), list: vi.fn().mockReturnValue(of(collection)), listMaintenanceLogs: vi.fn().mockReturnValue( of({ @@ -111,6 +115,8 @@ describe('EquipmentStore', () => { selectedEquipment: signal(null), isLoadingEquipment: signal(false), setEquipment: vi.fn(), + selectedOrganizationId: signal('org-1'), + selectionGeneration: signal(1), }; TestBed.configureTestingModule({ @@ -129,6 +135,90 @@ describe('EquipmentStore', () => { store = TestBed.inject(EquipmentStore); }); + describe('late mutation responses', () => { + let active: ActiveEquipmentStore; + const original = { + ...equipment, + organizationId: 'org-1', + locationLabel: 'Hall A', + } as EquipmentOutput; + const current = { + ...equipment, + id: 'equipment-2', + organizationId: 'org-1', + name: 'Pump B', + } as EquipmentOutput; + + beforeEach(() => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + EquipmentStore, + { provide: Dispatcher, useValue: mockDispatcher }, + { provide: EquipmentService, useValue: mockEquipmentService }, + { provide: PLATFORM_ID, useValue: 'browser' }, + ], + }); + active = TestBed.inject(ActiveEquipmentStore); + active.setEquipment(original); + store = TestBed.inject(EquipmentStore); + }); + + it('retains the confirmed A update without replacing the active B dossier', () => { + const response = new Subject(); + mockEquipmentService.update.mockReturnValue(response); + store.update({ + organizationId: 'org-1', + equipmentId: original.id, + input: { name: 'Saved A' }, + }); + active.setEquipment(current); + response.next({ id: original.id, name: 'Saved A' } as EquipmentOutput); + response.complete(); + expect(active.selectedEquipment()).toEqual(current); + expect(store.equipmentEntityMap()[original.id]).toMatchObject({ + name: 'Saved A', + locationLabel: 'Hall A', + }); + expect(store.updateCallState().status).toBe('success'); + store.update({ + organizationId: 'org-1', + equipmentId: current.id, + input: { name: 'Saved B' }, + }); + expect(mockEquipmentService.update).toHaveBeenLastCalledWith('org-1', current.id, { + name: 'Saved B', + }); + }); + + it('does not apply a late lifecycle response to another selection or a later visit to A', () => { + const response = new Subject(); + mockEquipmentService.decommission.mockReturnValue(response); + store.decommission({ organizationId: 'org-1', equipmentId: original.id }); + active.setEquipment(current); + active.setEquipment({ ...original, name: 'Revisited A' }); + response.next({ ...original, status: 'decommissioned' }); + response.complete(); + expect(active.selectedEquipment()).toMatchObject({ name: 'Revisited A' }); + expect(store.equipmentEntityMap()[original.id].status).toBe('decommissioned'); + }); + + it('does not sync a write when the organization changed with the same equipment id', () => { + const response = new Subject(); + mockEquipmentService.commission.mockReturnValue(response); + store.commission({ organizationId: 'org-1', equipmentId: original.id }); + const otherOrganization = { + ...original, + organizationId: 'org-2', + name: 'Other organization', + }; + active.setEquipment(otherOrganization); + response.next({ ...original, status: 'operational' }); + response.complete(); + expect(active.selectedEquipment()).toEqual(otherOrganization); + }); + }); + it('should load equipment', async () => { store.load({ organizationId: 'org-1' }); await flushEffects(); @@ -224,7 +314,14 @@ describe('EquipmentStore', () => { expect(mockEquipmentService.update).toHaveBeenCalledWith('org-1', 'equipment-1', {}); expect(store.updateCallState().status).toBe('success'); - expect(mockActiveEquipmentStore.setEquipment).toHaveBeenCalledWith(equipment); + expect(mockActiveEquipmentStore.setEquipment).toHaveBeenCalledWith( + equipment, + expect.objectContaining({ + organizationId: 'org-1', + equipmentId: 'equipment-1', + selectionGeneration: 1, + }), + ); }); it('should set the update error and dispatch updateFailed on error', async () => { @@ -254,7 +351,14 @@ describe('EquipmentStore', () => { facilityId: 'facility-1', }); expect(store.assignToFacilityCallState().status).toBe('success'); - expect(mockActiveEquipmentStore.setEquipment).toHaveBeenCalledWith(equipment); + expect(mockActiveEquipmentStore.setEquipment).toHaveBeenCalledWith( + equipment, + expect.objectContaining({ + organizationId: 'org-1', + equipmentId: 'equipment-1', + selectionGeneration: 1, + }), + ); }); it('should set the error and dispatch assignToFacilityFailed on error', async () => { @@ -284,11 +388,14 @@ describe('EquipmentStore', () => { 'equipment-1', ); expect(store.unassignFromFacilityCallState().status).toBe('success'); - expect(mockActiveEquipmentStore.setEquipment).toHaveBeenCalledWith({ - ...equipment, - facilityId: null, - facilityName: null, - }); + expect(mockActiveEquipmentStore.setEquipment).toHaveBeenCalledWith( + { + ...equipment, + facilityId: null, + facilityName: null, + }, + expect.objectContaining({ selectionGeneration: 1 }), + ); }); it('should set the error and dispatch unassignFromFacilityFailed on error', async () => { @@ -338,7 +445,10 @@ describe('EquipmentStore', () => { locationLabel: 'Hall east wall', }); expect(store.equipmentEntityMap()['equipment-1']).toEqual(merged); - expect(mockActiveEquipmentStore.setEquipment).toHaveBeenCalledWith(merged); + expect(mockActiveEquipmentStore.setEquipment).toHaveBeenCalledWith( + merged, + expect.objectContaining({ selectionGeneration: 1 }), + ); }); it('should clear the facility relation on unassign even when the response omits it', async () => { @@ -361,7 +471,14 @@ describe('EquipmentStore', () => { expect(mockEquipmentService.commission).toHaveBeenCalledWith('org-1', 'equipment-1'); expect(store.commissionCallState().status).toBe('success'); - expect(mockActiveEquipmentStore.setEquipment).toHaveBeenCalledWith(equipment); + expect(mockActiveEquipmentStore.setEquipment).toHaveBeenCalledWith( + equipment, + expect.objectContaining({ + organizationId: 'org-1', + equipmentId: 'equipment-1', + selectionGeneration: 1, + }), + ); }); it('should reflect isChangingLifecycle while pending and set the error on failure', async () => { @@ -385,7 +502,14 @@ describe('EquipmentStore', () => { expect(mockEquipmentService.decommission).toHaveBeenCalledWith('org-1', 'equipment-1'); expect(store.decommissionCallState().status).toBe('success'); - expect(mockActiveEquipmentStore.setEquipment).toHaveBeenCalledWith(equipment); + expect(mockActiveEquipmentStore.setEquipment).toHaveBeenCalledWith( + equipment, + expect.objectContaining({ + organizationId: 'org-1', + equipmentId: 'equipment-1', + selectionGeneration: 1, + }), + ); }); it('should set the error and dispatch decommissionFailed on error', async () => { @@ -408,7 +532,14 @@ describe('EquipmentStore', () => { expect(mockEquipmentService.maintenance).toHaveBeenCalledWith('org-1', 'equipment-1'); expect(store.maintenanceCallState().status).toBe('success'); - expect(mockActiveEquipmentStore.setEquipment).toHaveBeenCalledWith(equipment); + expect(mockActiveEquipmentStore.setEquipment).toHaveBeenCalledWith( + equipment, + expect.objectContaining({ + organizationId: 'org-1', + equipmentId: 'equipment-1', + selectionGeneration: 1, + }), + ); }); it('should set the error and dispatch maintenanceFailed on error', async () => { @@ -560,10 +691,13 @@ describe('EquipmentStore', () => { store.addTag({ organizationId: 'org-1', equipmentId: 'equipment-1', input: {} as never }); await flushEffects(); - expect(mockActiveEquipmentStore.setEquipment).toHaveBeenCalledWith({ - ...activeEquipment, - tags: [tag], - }); + expect(mockActiveEquipmentStore.setEquipment).toHaveBeenCalledWith( + { + ...activeEquipment, + tags: [tag], + }, + expect.objectContaining({ selectionGeneration: 1 }), + ); }); it('should not sync ActiveEquipmentStore when the active equipment already has the tag', async () => { @@ -624,10 +758,13 @@ describe('EquipmentStore', () => { store.removeTag({ organizationId: 'org-1', equipmentId: 'equipment-1', tagId: 'tag-1' }); await flushEffects(); - expect(mockActiveEquipmentStore.setEquipment).toHaveBeenCalledWith({ - ...activeEquipment, - tags: [], - }); + expect(mockActiveEquipmentStore.setEquipment).toHaveBeenCalledWith( + { + ...activeEquipment, + tags: [], + }, + expect.objectContaining({ selectionGeneration: 1 }), + ); }); it('should not sync ActiveEquipmentStore when the active equipment does not match', async () => { diff --git a/src/app/features/organization/features/equipments/state/index.ts b/src/app/features/organization/features/equipments/state/index.ts index c86ba2777..a2d17f55b 100644 --- a/src/app/features/organization/features/equipments/state/index.ts +++ b/src/app/features/organization/features/equipments/state/index.ts @@ -8,3 +8,13 @@ export { activeEquipmentStoreEvents } from './active-equipment'; export { equipmentStoreEvents } from './equipment'; export { EquipmentKpisStore } from './equipment-kpis'; export type { EquipmentKpisStoreType } from './equipment-kpis'; +export { + EquipmentTypeCatalogStore, + type EquipmentTypeCatalogStoreType, +} from './equipment-type-catalog'; +export { + EquipmentReplacementStore, + type EquipmentReplacementStoreType, +} from './equipment-replacement'; +export { EquipmentInspectionSummaryStore } from './equipment-inspection-summary'; +export { EquipmentOpenWorkStore } from './equipment-open-work'; diff --git a/src/app/features/organization/features/equipments/ui/components/equipment-information-panel/equipment-information-panel.component.html b/src/app/features/organization/features/equipments/ui/components/equipment-information-panel/equipment-information-panel.component.html index e1f10ea30..a9e4bf16c 100644 --- a/src/app/features/organization/features/equipments/ui/components/equipment-information-panel/equipment-information-panel.component.html +++ b/src/app/features/organization/features/equipments/ui/components/equipment-information-panel/equipment-information-panel.component.html @@ -7,6 +7,57 @@ Identification +
+ + {{ equipment().name || '—' }} + + +
+
+ + {{ equipment().assetCode || '—' }} + + +
+
- @for (option of typeOptions; track option.value) { + @for (option of editableTypeOptions(); track option.value) { {{ option.label }} @@ -72,8 +123,7 @@ type="text" class="w-full mobile-ui:min-h-11" [attr.aria-labelledby]="subTypeField.labelId" - [value]="textDraft()" - (input)="textDraft.set($any($event.target).value)" + [formField]="textForm.value" />
@@ -106,8 +156,7 @@ type="text" class="w-full mobile-ui:min-h-11" [attr.aria-labelledby]="brandField.labelId" - [value]="textDraft()" - (input)="textDraft.set($any($event.target).value)" + [formField]="textForm.value" /> @@ -140,8 +189,7 @@ type="text" class="w-full mobile-ui:min-h-11" [attr.aria-labelledby]="modelField.labelId" - [value]="textDraft()" - (input)="textDraft.set($any($event.target).value)" + [formField]="textForm.value" /> @@ -174,8 +222,7 @@ type="text" class="w-full mobile-ui:min-h-11" [attr.aria-labelledby]="serialField.labelId" - [value]="textDraft()" - (input)="textDraft.set($any($event.target).value)" + [formField]="textForm.value" /> @@ -217,8 +264,7 @@ type="text" class="w-full mobile-ui:min-h-11" [attr.aria-labelledby]="locationField.labelId" - [value]="textDraft()" - (input)="textDraft.set($any($event.target).value)" + [formField]="textForm.value" /> diff --git a/src/app/features/organization/features/equipments/ui/components/equipment-information-panel/equipment-information-panel.component.ts b/src/app/features/organization/features/equipments/ui/components/equipment-information-panel/equipment-information-panel.component.ts index 30e22b59d..d7cc320c5 100644 --- a/src/app/features/organization/features/equipments/ui/components/equipment-information-panel/equipment-information-panel.component.ts +++ b/src/app/features/organization/features/equipments/ui/components/equipment-information-panel/equipment-information-panel.component.ts @@ -13,6 +13,7 @@ import { type Signal, type WritableSignal, } from '@angular/core'; +import { form, FormField, maxLength, type FieldTree } from '@angular/forms/signals'; import type { EquipmentEditState, EquipmentEditTarget, @@ -41,7 +42,6 @@ import { HlmSelectImports } from '@shared/ui/select'; * There is no separate edit page: `type` commits on selection (`pick`); the * five free-text fields keep an explicit Save (`confirm`) since text has no * single "done" gesture. - * * Only one field is ever open at a time (`editState`), so the five confirm * fields share a single draft signal rather than one each. Grouped into an * "Identification" and a "Placement & lifecycle" fieldset, the second @@ -54,18 +54,36 @@ import { HlmSelectImports } from '@shared/ui/select'; */ @Component({ selector: 'app-equipment-information-panel', - imports: [InplaceField, OrgDatePipe, HlmInput, ...HlmSelectImports], + imports: [InplaceField, OrgDatePipe, HlmInput, FormField, ...HlmSelectImports], templateUrl: './equipment-information-panel.component.html', changeDetection: ChangeDetectionStrategy.OnPush, }) export class EquipmentInformationPanel { //#region Inputs + /** + * Property typeOptions + * @readonly + * + * @description + * Server catalog containing both active and archived labels. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly typeOptions: InputSignal = + input(EQUIPMENT_TYPE_OPTIONS); /** * Property equipment * @readonly - * @description The loaded equipment whose properties this panel edits. + * + * @description + * The loaded equipment whose properties this panel edits. + * * @access public * @since 1.0.0 + * * @type {InputSignal} */ public readonly equipment: InputSignal = input.required(); @@ -73,9 +91,13 @@ export class EquipmentInformationPanel { /** * Property editable * @readonly - * @description Whether the member may write to this equipment at all. + * + * @description + * Whether the member may write to this equipment at all. + * * @access public * @since 1.0.0 + * * @type {InputSignalWithTransform} */ public readonly editable: InputSignalWithTransform = input< @@ -86,9 +108,13 @@ export class EquipmentInformationPanel { /** * Property editState * @readonly - * @description Which field the page has open, writing, or showing a rejection. + * + * @description + * Which field the page has open, writing, or showing a rejection. + * * @access public * @since 1.0.0 + * * @type {InputSignal} */ public readonly editState: InputSignal = input.required(); @@ -96,9 +122,14 @@ export class EquipmentInformationPanel { /** * Property regionalFormatting * @readonly - * @description The organization's date pattern and timezone, for the Installed/Commissioned rows' `appOrgDate` binding. + * + * @description + * The organization's date pattern and timezone, for the Installed/Commissioned rows' `appOrgDate` + * binding. + * * @access public * @since 1.1.0 + * * @type {InputSignal} */ public readonly regionalFormatting: InputSignal = @@ -109,9 +140,13 @@ export class EquipmentInformationPanel { /** * Property detailsChanged * @readonly - * @description A patch the page should send. Never emitted for an unchanged value. + * + * @description + * A patch the page should send. Never emitted for an unchanged value. + * * @access public * @since 1.0.0 + * * @type {OutputEmitterRef} */ public readonly detailsChanged: OutputEmitterRef = @@ -120,9 +155,13 @@ export class EquipmentInformationPanel { /** * Property editTargetChanged * @readonly - * @description Asks the page to open or close an editor. + * + * @description + * Asks the page to open or close an editor. + * * @access public * @since 1.0.0 + * * @type {OutputEmitterRef} */ public readonly editTargetChanged: OutputEmitterRef = @@ -130,11 +169,27 @@ export class EquipmentInformationPanel { //#endregion //#region Properties - /** The equipment types offered by the `type` select. */ - protected readonly typeOptions: typeof EQUIPMENT_TYPE_OPTIONS = EQUIPMENT_TYPE_OPTIONS; + /** + * Property editableTypeOptions + * @readonly + * + * @description + * Active catalog choices plus the record's archived historical type. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly editableTypeOptions: Signal = computed(() => + this.typeOptions().filter( + (option) => + !('archived' in option) || !option.archived || option.value === this.equipment().type, + ), + ); /** - * Property textDraft + * Property textModel * @readonly * * @description @@ -145,44 +200,75 @@ export class EquipmentInformationPanel { * * @access protected * @since 1.0.0 - * @type {WritableSignal} + * + * @type {WritableSignal<{ value: string }>} + */ + protected readonly textModel: WritableSignal<{ value: string }> = signal({ value: '' }); + + /** + * Property textForm + * @readonly + * + * @description + * Signal Form for the currently open identity field. + * + * @access protected + * @since unreleased + * + * @type {FieldTree<{ value: string }>} */ - protected readonly textDraft: WritableSignal = signal(''); + protected readonly textForm: FieldTree<{ value: string }> = form(this.textModel, (path) => + maxLength(path.value, () => (this.editState().open === 'assetCode' ? 100 : 255)), + ); /** * Property canSaveText * @readonly - * @description Whether the open text field's draft differs from its stored value. + * + * @description + * Whether the open text field's draft differs from its stored value. + * * @access protected * @since 1.0.0 + * * @type {Signal} */ protected readonly canSaveText: Signal = computed(() => { const stored: string = this.storedValueOf(this.editState().open) ?? ''; - return this.textDraft().trim() !== stored; + return this.textModel().value.trim() !== stored && this.textForm().valid(); }); /** - * Names an equipment type on the closed select trigger. + * Property typeLabelOf + * @readonly + * + * @description + * Names server catalog choices and preserves readable custom-code fallbacks. + * + * @access protected + * @since unreleased * - * `EquipmentOutput.type` is a raw `string` (the backend keeps the field - * open beyond the current catalog), so this reads `string`, not - * `EquipmentType`, and names one the catalog does not know "Unknown type" - * rather than printing the raw key. + * @type {(value: string) => string} */ protected readonly typeLabelOf: (value: string) => string = (value) => - this.typeOptions.find((option) => option.value === (value as EquipmentType))?.label ?? - $localize`:@@common.unknownType:Unknown type`; + this.typeOptions().find((option) => option.value === (value as EquipmentType))?.label ?? + value.replaceAll('_', ' '); //#endregion //#region Methods /** * Method isEditing - * @description Whether the page has this field open. + * @method isEditing + * + * @description + * Whether the page has this field open. + * * @access protected * @since 1.0.0 + * * @param {EquipmentEditTarget} target - The field in question. + * * @returns {boolean} True when it is the open one. */ protected isEditing(target: EquipmentEditTarget): boolean { @@ -191,10 +277,16 @@ export class EquipmentInformationPanel { /** * Method isSaving - * @description Whether this field's own write is in flight. + * @method isSaving + * + * @description + * Whether this field's own write is in flight. + * * @access protected * @since 1.0.0 + * * @param {EquipmentEditTarget} target - The field in question. + * * @returns {boolean} True while its patch is pending. */ protected isSaving(target: EquipmentEditTarget): boolean { @@ -203,10 +295,16 @@ export class EquipmentInformationPanel { /** * Method errorFor - * @description The rejection message attributed to this field, if any. + * @method errorFor + * + * @description + * The rejection message attributed to this field, if any. + * * @access protected * @since 1.0.0 + * * @param {EquipmentEditTarget} target - The field in question. + * * @returns {string | null} Its failure message, or null. */ protected errorFor(target: EquipmentEditTarget): string | null { @@ -217,6 +315,7 @@ export class EquipmentInformationPanel { /** * Method onTextEditing + * @method onTextEditing * * @description * Seeds the shared draft on open and forwards the open/close request to the @@ -231,33 +330,50 @@ export class EquipmentInformationPanel { * @returns {void} */ protected onTextEditing(target: EquipmentEditTarget, open: boolean): void { - if (open) this.textDraft.set(this.storedValueOf(target) ?? ''); + if (open) this.textForm().reset({ value: this.storedValueOf(target) ?? '' }); this.editTargetChanged.emit(open ? target : null); } /** * Method saveText - * @description Emits the drafted value for the currently open text field, trimmed and nulled if blank. + * @method saveText + * + * @description + * Emits the drafted value for the currently open text field, trimmed and nulled if blank. + * * @access protected * @since 1.0.0 + * * @returns {void} */ protected saveText(): void { const target: EquipmentEditTarget | null = this.editState().open; - if (target === null || target === 'type') return; + if ( + target === null || + target === 'type' || + target === 'technicalProperties' || + this.textForm().invalid() + ) + return; - const trimmed: string = this.textDraft().trim(); + const trimmed: string = this.textModel().value.trim(); this.detailsChanged.emit({ [target]: trimmed === '' ? null : trimmed }); } /** * Method pickType - * @description Commits a picked type, unless it is the one already stored. + * @method pickType + * + * @description + * Commits a picked type, unless it is the one already stored. + * * @access protected * @since 1.0.0 + * * @param {EquipmentType} type - The chosen type. + * * @returns {void} */ protected pickType(type: EquipmentType): void { @@ -268,10 +384,16 @@ export class EquipmentInformationPanel { /** * Method onTypeEditing - * @description Forwards the `type` field's open/close request. + * @method onTypeEditing + * + * @description + * Forwards the `type` field's open/close request. + * * @access protected * @since 1.0.0 + * * @param {boolean} open - Whether it is being opened. + * * @returns {void} */ protected onTypeEditing(open: boolean): void { @@ -280,16 +402,22 @@ export class EquipmentInformationPanel { /** * Method storedValueOf - * @description The currently stored value for a text edit target, or null for `type`/unset. + * @method storedValueOf + * + * @description + * The currently stored value for a text edit target, or null for `type`/unset. + * * @access private * @since 1.0.0 + * * @param {EquipmentEditTarget | null} target - The field in question. + * * @returns {string | null} The stored value. */ private storedValueOf(target: EquipmentEditTarget | null): string | null { - if (target === null || target === 'type') return null; + if (target === null || target === 'type' || target === 'technicalProperties') return null; - return this.equipment()[target]; + return this.equipment()[target] ?? null; } //#endregion } diff --git a/src/app/features/organization/features/equipments/ui/components/index.ts b/src/app/features/organization/features/equipments/ui/components/index.ts new file mode 100644 index 000000000..924f49481 --- /dev/null +++ b/src/app/features/organization/features/equipments/ui/components/index.ts @@ -0,0 +1 @@ +export { EquipmentStatusTag } from './equipment-status-tag'; diff --git a/src/app/features/organization/features/equipments/ui/dialogs/equipment-replacement-confirm-dialog/equipment-replacement-confirm-dialog.component.html b/src/app/features/organization/features/equipments/ui/dialogs/equipment-replacement-confirm-dialog/equipment-replacement-confirm-dialog.component.html new file mode 100644 index 000000000..4af696d75 --- /dev/null +++ b/src/app/features/organization/features/equipments/ui/dialogs/equipment-replacement-confirm-dialog/equipment-replacement-confirm-dialog.component.html @@ -0,0 +1,43 @@ + + + +

+ Confirm the replacement +

+

+ {{ equipmentName() }} will be retired permanently. Its dossier and history remain accessible + through the successor. +

+
+ + + + +
+
diff --git a/src/app/features/organization/features/equipments/ui/dialogs/equipment-replacement-confirm-dialog/equipment-replacement-confirm-dialog.component.ts b/src/app/features/organization/features/equipments/ui/dialogs/equipment-replacement-confirm-dialog/equipment-replacement-confirm-dialog.component.ts new file mode 100644 index 000000000..e182bfa4b --- /dev/null +++ b/src/app/features/organization/features/equipments/ui/dialogs/equipment-replacement-confirm-dialog/equipment-replacement-confirm-dialog.component.ts @@ -0,0 +1,83 @@ +import { + ChangeDetectionStrategy, + Component, + input, + output, + type InputSignal, + type OutputEmitterRef, +} from '@angular/core'; +import { HlmAlertDialogImports } from '@shared/ui/alert-dialog'; +import { HlmButton } from '@shared/ui/button'; + +/** + * Class EquipmentReplacementConfirmDialog + * @class EquipmentReplacementConfirmDialog + * + * @description + * Final explicit confirmation before the historical equipment is irreversibly retired. + */ +@Component({ + selector: 'app-equipment-replacement-confirm-dialog', + imports: [HlmButton, ...HlmAlertDialogImports], + templateUrl: './equipment-replacement-confirm-dialog.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class EquipmentReplacementConfirmDialog { + //#region Properties + /** + * Property visible + * @readonly + * + * @description + * Whether the replacement decision needs confirmation. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly visible: InputSignal = input(false); + + /** + * Property equipmentName + * @readonly + * + * @description + * Historical identity that will be retired. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly equipmentName: InputSignal = input(''); + + /** + * Property confirmed + * @readonly + * + * @description + * Operator accepted the terminal replacement. + * + * @access public + * @since unreleased + * + * @type {OutputEmitterRef} + */ + public readonly confirmed: OutputEmitterRef = output(); + + /** + * Property cancelled + * @readonly + * + * @description + * Operator returned to the preserved replacement draft. + * + * @access public + * @since unreleased + * + * @type {OutputEmitterRef} + */ + public readonly cancelled: OutputEmitterRef = output(); + //#endregion +} diff --git a/src/app/features/organization/features/equipments/ui/dialogs/equipment-replacement-confirm-dialog/index.ts b/src/app/features/organization/features/equipments/ui/dialogs/equipment-replacement-confirm-dialog/index.ts new file mode 100644 index 000000000..09d1dea77 --- /dev/null +++ b/src/app/features/organization/features/equipments/ui/dialogs/equipment-replacement-confirm-dialog/index.ts @@ -0,0 +1 @@ +export { EquipmentReplacementConfirmDialog } from './equipment-replacement-confirm-dialog.component'; diff --git a/src/app/features/organization/features/equipments/ui/forms/equipment-characteristics-form/equipment-characteristics-form.component.html b/src/app/features/organization/features/equipments/ui/forms/equipment-characteristics-form/equipment-characteristics-form.component.html new file mode 100644 index 000000000..e5e3f5b45 --- /dev/null +++ b/src/app/features/organization/features/equipments/ui/forms/equipment-characteristics-form/equipment-characteristics-form.component.html @@ -0,0 +1,140 @@ +
+ @if (error()) { + {{ error() }} + } + + + + + + @for (option of criticalityOptions; track option.value) { + {{ + option.label + }} + } + + + +
+ + Technical characteristics + +

+ Declared information about this equipment; it does not determine control obligations. +

+ @for (row of draft().properties; track $index) { +
+ + + + @if ( + characteristicsForm.properties[$index].key().touched() && + characteristicsForm.properties[$index].key().invalid() + ) { + {{ + characteristicsForm.properties[$index].key().errors()[0].message + }} + } + + + + + @if ( + characteristicsForm.properties[$index].value().touched() && + characteristicsForm.properties[$index].value().invalid() + ) { + {{ + characteristicsForm.properties[$index].value().errors()[0].message + }} + } + + + + + + +
+ } + +
+
+ + +
+
diff --git a/src/app/features/organization/features/equipments/ui/forms/equipment-characteristics-form/equipment-characteristics-form.component.ts b/src/app/features/organization/features/equipments/ui/forms/equipment-characteristics-form/equipment-characteristics-form.component.ts new file mode 100644 index 000000000..dd07b1524 --- /dev/null +++ b/src/app/features/organization/features/equipments/ui/forms/equipment-characteristics-form/equipment-characteristics-form.component.ts @@ -0,0 +1,347 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + input, + output, + signal, + untracked, + type InputSignal, + type OutputEmitterRef, + type Signal, + type WritableSignal, +} from '@angular/core'; +import { + applyEach, + disabled, + form, + FormField, + maxLength, + required, + validate, + type FieldTree, +} from '@angular/forms/signals'; +import type { + EquipmentOutput, + EquipmentCriticality, + UpdateEquipmentInput, +} from '@features/organization/features/equipments/models'; +import { HlmButton } from '@shared/ui/button'; +import { HlmFieldImports } from '@shared/ui/field'; +import { HlmInput } from '@shared/ui/input'; +import { HlmSelectImports } from '@shared/ui/select'; +import type { EquipmentCharacteristicsDraft } from './models/equipment-characteristics-draft.interface'; + +/** + * Class EquipmentCharacteristicsForm + * @class EquipmentCharacteristicsForm + * + * @description + * Edits declarative equipment characteristics without making regulatory inferences. + */ +@Component({ + selector: 'app-equipment-characteristics-form', + imports: [FormField, HlmButton, HlmInput, ...HlmFieldImports, ...HlmSelectImports], + templateUrl: './equipment-characteristics-form.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class EquipmentCharacteristicsForm { + //#region Properties + /** + * Property equipment + * @readonly + * + * @description + * Equipment whose declared characteristics are being edited. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly equipment: InputSignal = input.required(); + + /** + * Property pending + * @readonly + * + * @description + * Locks the draft while the owning page persists it. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly pending: InputSignal = input(false); + + /** + * Property error + * @readonly + * + * @description + * Recoverable server rejection; the draft is retained. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly error: InputSignal = input(null); + + /** + * Property submitted + * @readonly + * + * @description + * Complete normalized patch for the owning page to persist. + * + * @access public + * @since unreleased + * + * @type {OutputEmitterRef} + */ + public readonly submitted: OutputEmitterRef = + output(); + + /** + * Property cancelled + * @readonly + * + * @description + * Operator closed the editor without saving. + * + * @access public + * @since unreleased + * + * @type {OutputEmitterRef} + */ + public readonly cancelled: OutputEmitterRef = output(); + + /** + * Property draft + * @readonly + * + * @description + * Editable characteristic rows and declared criticality. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal} + */ + protected readonly draft: WritableSignal = signal({ + criticality: '', + properties: [], + }); + + /** + * Property criticalityOptions + * @readonly + * + * @description + * Localized declared-impact choices. + * + * @access protected + * @since unreleased + * + * @type {readonly { readonly value: EquipmentCriticality | ''; readonly label: string }[]} + */ + protected readonly criticalityOptions: readonly { + readonly value: EquipmentCriticality | ''; + readonly label: string; + }[] = [ + { value: '', label: $localize`:@@equipment.criticality.unknown:Not specified` }, + { value: 'low', label: $localize`:@@equipment.criticality.low:Low` }, + { value: 'medium', label: $localize`:@@equipment.criticality.medium:Medium` }, + { value: 'high', label: $localize`:@@equipment.criticality.high:High` }, + { value: 'critical', label: $localize`:@@equipment.criticality.critical:Critical` }, + ]; + + /** + * Property characteristicsForm + * @readonly + * + * @description + * Signal Form enforcing transport limits and unique characteristic keys. + * + * @access protected + * @since unreleased + * + * @type {FieldTree} + */ + protected readonly characteristicsForm: FieldTree = form( + this.draft, + (path) => { + disabled(path, { when: () => this.pending() }); + applyEach(path.properties, (row) => { + required(row.key, { + message: $localize`:@@equipment.characteristics.keyRequired:Enter a characteristic name.`, + }); + required(row.value, { + message: $localize`:@@equipment.characteristics.valueRequired:Enter a value.`, + }); + maxLength(row.key, 64); + maxLength(row.value, 255); + maxLength(row.unit, 32); + validate(row.value, ({ value }) => + value().trim() + ? null + : { + kind: 'blank', + message: $localize`:@@equipment.characteristics.valueRequired:Enter a value.`, + }, + ); + validate(row.key, ({ value, valueOf }) => { + const key = value().trim(); + if (!key) + return { + kind: 'blank', + message: $localize`:@@equipment.characteristics.keyRequired:Enter a characteristic name.`, + }; + const count = valueOf(path.properties).filter((entry) => entry.key.trim() === key).length; + return count > 1 + ? { + kind: 'duplicate', + message: $localize`:@@equipment.characteristics.duplicateKey:Characteristic names must be unique.`, + } + : null; + }); + }); + }, + ); + + /** + * Property canAdd + * @readonly + * + * @description + * API supports at most fifty declared characteristic rows. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly canAdd: Signal = computed( + () => this.draft().properties.length < 50 && !this.pending(), + ); + /** + * Property seededEquipmentScope + * + * @description + * Keeps unrelated updates of the same equipment from erasing an edited characteristics draft. + * + * @access private + * @since unreleased + * + * @type {string | null} + */ + private seededEquipmentScope: string | null = null; + //#endregion + + //#region Constructor + /** + * Constructor + * @constructor + * + * @description + * Seeds the draft from the equipment without making the initial form dirty. + * + * @access public + * @since unreleased + */ + public constructor() { + effect(() => { + const equipment = this.equipment(); + untracked(() => { + const scope = `${equipment.organizationId}/${equipment.id}`; + if (this.seededEquipmentScope === scope && this.characteristicsForm().dirty()) return; + this.seededEquipmentScope = scope; + this.characteristicsForm().reset({ + criticality: equipment.criticality ?? '', + properties: (equipment.technicalProperties ?? []).map((entry) => ({ + key: entry.key, + value: entry.value, + unit: entry.unit ?? '', + })), + }); + }); + }); + } + //#endregion + + //#region Methods + /** + * Method add + * @method add + * + * @description + * Adds a blank characteristic without exceeding the API limit. + * + * @access protected + * @since unreleased + * + * @returns {void} Result of the operation. + */ + protected add(): void { + if (!this.canAdd()) return; + this.draft.update((draft) => ({ + ...draft, + properties: [...draft.properties, { key: '', value: '', unit: '' }], + })); + } + + /** + * Method remove + * @method remove + * + * @description + * Removes the selected draft row. + * + * @access protected + * @since unreleased + * + * @param {number} index - index. + * + * @returns {void} Result of the operation. + */ + protected remove(index: number): void { + if (this.pending()) return; + this.draft.update((draft) => ({ + ...draft, + properties: draft.properties.filter((_, row) => row !== index), + })); + } + + /** + * Method submit + * @method submit + * + * @description + * Validates and emits values while retaining the draft until server success. + * + * @access protected + * @since unreleased + * + * @param {Event} event - event. + * + * @returns {void} Result of the operation. + */ + protected submit(event: Event): void { + event.preventDefault(); + this.characteristicsForm().markAsTouched(); + if (this.pending() || this.characteristicsForm().invalid()) return; + const draft = this.draft(); + this.submitted.emit({ + criticality: draft.criticality || null, + technicalProperties: draft.properties.map((entry) => ({ + key: entry.key.trim(), + value: entry.value.trim(), + unit: entry.unit.trim() || null, + })), + }); + } + //#endregion +} diff --git a/src/app/features/organization/features/equipments/ui/forms/equipment-characteristics-form/index.ts b/src/app/features/organization/features/equipments/ui/forms/equipment-characteristics-form/index.ts new file mode 100644 index 000000000..9a8cb8182 --- /dev/null +++ b/src/app/features/organization/features/equipments/ui/forms/equipment-characteristics-form/index.ts @@ -0,0 +1 @@ +export { EquipmentCharacteristicsForm } from './equipment-characteristics-form.component'; diff --git a/src/app/features/organization/features/equipments/ui/forms/equipment-characteristics-form/models/equipment-characteristics-draft.interface.ts b/src/app/features/organization/features/equipments/ui/forms/equipment-characteristics-form/models/equipment-characteristics-draft.interface.ts new file mode 100644 index 000000000..d6e91e42d --- /dev/null +++ b/src/app/features/organization/features/equipments/ui/forms/equipment-characteristics-form/models/equipment-characteristics-draft.interface.ts @@ -0,0 +1,30 @@ +import type { EquipmentCriticality } from '@features/organization/features/equipments/models'; + +/** + * Interface EquipmentCharacteristicsDraft + * @interface EquipmentCharacteristicsDraft + * + * @description + * Signal Form draft for declared impact and technical characteristics. + */ +export interface EquipmentCharacteristicsDraft { + /** + * Property criticality + * + * @description + * Declared impact, or an empty value when it is unknown. + */ + readonly criticality: EquipmentCriticality | ''; + + /** + * Property properties + * + * @description + * Edited technical values; empty units remain ordinary text drafts. + */ + readonly properties: readonly { + readonly key: string; + readonly value: string; + readonly unit: string; + }[]; +} diff --git a/src/app/features/organization/features/equipments/ui/forms/equipment-characteristics-form/testing/equipment-characteristics-form.component.spec.ts b/src/app/features/organization/features/equipments/ui/forms/equipment-characteristics-form/testing/equipment-characteristics-form.component.spec.ts new file mode 100644 index 000000000..874fdfca7 --- /dev/null +++ b/src/app/features/organization/features/equipments/ui/forms/equipment-characteristics-form/testing/equipment-characteristics-form.component.spec.ts @@ -0,0 +1,117 @@ +import { provideZonelessChangeDetection } from '@angular/core'; +import { TestBed, type ComponentFixture } from '@angular/core/testing'; +import type { + EquipmentOutput, + UpdateEquipmentInput, +} from '@features/organization/features/equipments/models'; +import { EquipmentCharacteristicsForm } from '../equipment-characteristics-form.component'; + +describe('EquipmentCharacteristicsForm', () => { + let fixture: ComponentFixture; + const equipment = { + '@id': '/api/equipment/eq', + '@type': 'Equipment', + id: 'eq', + organizationId: 'org', + facilityId: null, + facilityName: null, + type: 'fire_extinguisher', + subType: null, + brand: null, + model: null, + serialNumber: null, + locationLabel: null, + status: 'operational', + installedAt: null, + commissionedAt: null, + tags: [], + maintenanceDueStatus: 'unscheduled', + createdAt: '2026-01-01', + updatedAt: '2026-01-01', + technicalProperties: [{ key: 'Capacity', value: '6', unit: 'kg' }], + criticality: 'high', + } as EquipmentOutput; + beforeEach(async () => { + TestBed.configureTestingModule({ providers: [provideZonelessChangeDetection()] }); + fixture = TestBed.createComponent(EquipmentCharacteristicsForm); + fixture.componentRef.setInput('equipment', equipment); + await fixture.whenStable(); + }); + const submit = async () => { + fixture.nativeElement + .querySelector('form') + .dispatchEvent(new Event('submit', { cancelable: true })); + await fixture.whenStable(); + }; + + it('normalizes declared values and optional units without deriving any obligation', async () => { + const values: UpdateEquipmentInput[] = []; + fixture.componentInstance.submitted.subscribe((value) => values.push(value)); + fixture.componentInstance['draft'].set({ + criticality: '', + properties: [{ key: ' Capacity ', value: ' 6 ', unit: ' ' }], + }); + await fixture.whenStable(); + await submit(); + expect(values).toEqual([ + { criticality: null, technicalProperties: [{ key: 'Capacity', value: '6', unit: null }] }, + ]); + }); + + it('rejects duplicate keys after normalization and preserves both rows for correction', async () => { + const submitted = vi.fn(); + fixture.componentInstance.submitted.subscribe(submitted); + fixture.componentInstance['draft'].set({ + criticality: 'high', + properties: [ + { key: 'Capacity', value: '6', unit: 'kg' }, + { key: ' Capacity ', value: '7', unit: 'kg' }, + ], + }); + await fixture.whenStable(); + await submit(); + expect(submitted).not.toHaveBeenCalled(); + expect(fixture.nativeElement.textContent).toContain('Characteristic names must be unique.'); + expect(fixture.componentInstance['draft']().properties).toHaveLength(2); + }); + + it('rejects whitespace-only values instead of sending a misleading empty characteristic', async () => { + const submitted = vi.fn(); + fixture.componentInstance.submitted.subscribe(submitted); + fixture.componentInstance['draft'].set({ + criticality: '', + properties: [{ key: ' ', value: ' ', unit: '' }], + }); + await fixture.whenStable(); + await submit(); + expect(submitted).not.toHaveBeenCalled(); + expect(fixture.nativeElement.textContent).toContain('Enter a characteristic name.'); + expect(fixture.nativeElement.textContent).toContain('Enter a value.'); + }); + + it('preserves the edited draft after a server refusal and locks it only while pending', async () => { + fixture.componentInstance['draft'].set({ + criticality: 'low', + properties: [{ key: 'Capacity', value: '9', unit: 'kg' }], + }); + fixture.componentRef.setInput('error', 'Equipment revision has changed.'); + await fixture.whenStable(); + expect(fixture.componentInstance['draft']().properties[0].value).toBe('9'); + expect(fixture.nativeElement.textContent).toContain('Equipment revision has changed.'); + fixture.componentRef.setInput('pending', true); + await fixture.whenStable(); + expect(fixture.nativeElement.querySelector('input').disabled).toBe(true); + }); + + it('keeps a dirty characteristics draft when an unrelated site update refreshes the same equipment', async () => { + const root = fixture.nativeElement as HTMLElement; + const value = root.querySelector('#equipment-property-value-0'); + if (!value) throw new Error('Characteristic value was not rendered'); + value.value = '12'; + value.dispatchEvent(new Event('input')); + await fixture.whenStable(); + fixture.componentRef.setInput('equipment', { ...equipment, facilityId: 'other-site' }); + await fixture.whenStable(); + expect(fixture.componentInstance['draft']().properties[0].value).toBe('12'); + }); +}); diff --git a/src/app/features/organization/features/equipments/ui/forms/equipment-create-form/equipment-create-form.component.html b/src/app/features/organization/features/equipments/ui/forms/equipment-create-form/equipment-create-form.component.html index 1302d3079..9b69134dc 100644 --- a/src/app/features/organization/features/equipments/ui/forms/equipment-create-form/equipment-create-form.component.html +++ b/src/app/features/organization/features/equipments/ui/forms/equipment-create-form/equipment-create-form.component.html @@ -25,7 +25,7 @@ - @for (option of typeOptions; track option.value) { + @for (option of typeOptions(); track option.value) { {{ option.label }} @@ -39,21 +39,60 @@ - Equipment name - + @if (createForm.name().touched() && createForm.name().invalid()) { + {{ createForm.name().errors()[0].message }} + } + + + + + +

+ Unique within your organization when supplied. +

+ @if (createForm.assetCode().touched() && createForm.assetCode().invalid()) { + {{ createForm.assetCode().errors()[0].message }} + }
+ @if (!inheritPlacement()) { + + + + + } + @if (pending()) { Creating… + } @else if (replacement()) { + Confirm replacement } @else { Create equipment } diff --git a/src/app/features/organization/features/equipments/ui/forms/equipment-create-form/equipment-create-form.component.ts b/src/app/features/organization/features/equipments/ui/forms/equipment-create-form/equipment-create-form.component.ts index 45231ca30..ad0365d75 100644 --- a/src/app/features/organization/features/equipments/ui/forms/equipment-create-form/equipment-create-form.component.ts +++ b/src/app/features/organization/features/equipments/ui/forms/equipment-create-form/equipment-create-form.component.ts @@ -12,7 +12,14 @@ import { type Signal, type WritableSignal, } from '@angular/core'; -import { disabled, form, FormField, required, type FieldTree } from '@angular/forms/signals'; +import { + disabled, + form, + FormField, + maxLength, + required, + type FieldTree, +} from '@angular/forms/signals'; import { idleCallState, type CallState } from '@core/request-state'; import type { CreateEquipmentInput, @@ -37,6 +44,8 @@ import type { EquipmentCreateFormDraft } from './models'; * A blank draft. */ const EMPTY_VALUES: EquipmentCreateFormDraft = { + name: '', + assetCode: '', type: '', subType: '', brand: '', @@ -115,6 +124,47 @@ function trimmed(value: string): string | undefined { }) export class EquipmentCreateForm { //#region Inputs + /** + * Property inheritPlacement + * @readonly + * + * @description + * Hides site selection when the server inherits placement during replacement. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly inheritPlacement: InputSignal = input(false); + + /** + * Property replacement + * @readonly + * + * @description + * Names the atomic replacement confirmation rather than ordinary creation. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly replacement: InputSignal = input(false); + /** + * Property typeOptions + * @readonly + * + * @description + * Active choices supplied by the owning page's server catalog. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly typeOptions: InputSignal = + input(EQUIPMENT_TYPE_OPTIONS); /** * Property facilityPage * @readonly @@ -321,22 +371,10 @@ export class EquipmentCreateForm { required(path.type, { message: $localize`:@@equipment.form.typeRequired:Equipment type is required.`, }); + maxLength(path.name, 255); + maxLength(path.assetCode, 100); }); - /** - * Property typeOptions - * @readonly - * - * @description - * The equipment types offered. - * - * @access protected - * @since unreleased - * - * @type {typeof EQUIPMENT_TYPE_OPTIONS} - */ - protected readonly typeOptions: typeof EQUIPMENT_TYPE_OPTIONS = EQUIPMENT_TYPE_OPTIONS; - /** * Property serverMessages * @readonly @@ -372,7 +410,7 @@ export class EquipmentCreateForm { * @type {(value: EquipmentType | '') => string} */ protected readonly typeLabelOf: (value: EquipmentType | '') => string = (value) => - this.typeOptions.find((option) => option.value === value)?.label ?? ''; + this.typeOptions().find((option) => option.value === value)?.label ?? ''; //#endregion //#region Constructor @@ -415,6 +453,7 @@ export class EquipmentCreateForm { //#region Methods /** * Method submit + * @method submit * * @description * Marks the tree touched so every unmet rule shows at once, then emits @@ -440,6 +479,8 @@ export class EquipmentCreateForm { this.submitted.emit({ type: draft.type, + name: trimmed(draft.name), + assetCode: trimmed(draft.assetCode), subType: trimmed(draft.subType), brand: trimmed(draft.brand), model: trimmed(draft.model), diff --git a/src/app/features/organization/features/equipments/ui/forms/equipment-create-form/models/equipment-create-form-draft.model.ts b/src/app/features/organization/features/equipments/ui/forms/equipment-create-form/models/equipment-create-form-draft.model.ts index d4d744eb4..0efcc98cb 100644 --- a/src/app/features/organization/features/equipments/ui/forms/equipment-create-form/models/equipment-create-form-draft.model.ts +++ b/src/app/features/organization/features/equipments/ui/forms/equipment-create-form/models/equipment-create-form-draft.model.ts @@ -12,24 +12,74 @@ import type { EquipmentType } from '@features/organization/features/equipments/m * @since 1.0.0 */ export interface EquipmentCreateFormDraft { - /** The equipment type, or an empty string until one is picked. */ + /** + * Property name + * + * @description + * Optional human-readable equipment identity. + */ + readonly name: string; + + /** + * Property assetCode + * + * @description + * Optional organization-unique inventory reference. + */ + readonly assetCode: string; + /** + * Property type + * + * @description + * Stable catalog code, empty until a type is selected. + */ readonly type: EquipmentType | ''; - /** Free-text subtype refining the main type. */ + /** + * Property subType + * + * @description + * Declared subtype refining the equipment type. + */ readonly subType: string; - /** Manufacturer brand. */ + /** + * Property brand + * + * @description + * Manufacturer brand. + */ readonly brand: string; - /** Model reference. */ + /** + * Property model + * + * @description + * Manufacturer model reference. + */ readonly model: string; - /** Manufacturer serial number. */ + /** + * Property serialNumber + * + * @description + * Manufacturer serial number identifying the equipment. + */ readonly serialNumber: string; - /** Human-readable location inside the facility. */ + /** + * Property locationLabel + * + * @description + * Human-readable location within the assigned site. + */ readonly locationLabel: string; - /** The owning facility's id, or `''` when the equipment starts unassigned. */ + /** + * Property facility + * + * @description + * Assigned site identity, empty when unassigned. + */ readonly facility: string; } diff --git a/src/app/features/organization/features/equipments/ui/forms/equipment-create-form/testing/equipment-create-form.component.spec.ts b/src/app/features/organization/features/equipments/ui/forms/equipment-create-form/testing/equipment-create-form.component.spec.ts index 8660236a4..c06ea7b5a 100644 --- a/src/app/features/organization/features/equipments/ui/forms/equipment-create-form/testing/equipment-create-form.component.spec.ts +++ b/src/app/features/organization/features/equipments/ui/forms/equipment-create-form/testing/equipment-create-form.component.spec.ts @@ -58,6 +58,8 @@ describe('EquipmentCreateForm', () => { model: WritableSignal; } ).model.set({ + name: '', + assetCode: '', type: 'fire_extinguisher', subType: ' CO2 ', brand: 'Kidde', diff --git a/src/app/features/organization/features/equipments/ui/forms/equipment-type-form/equipment-type-form.component.html b/src/app/features/organization/features/equipments/ui/forms/equipment-type-form/equipment-type-form.component.html new file mode 100644 index 000000000..88c4ac349 --- /dev/null +++ b/src/app/features/organization/features/equipments/ui/forms/equipment-type-form/equipment-type-form.component.html @@ -0,0 +1,97 @@ +
+
+ Equipment type +
+
+ + +

+ The code cannot be changed after creation. +

+ @if (fields.value().touched()) { + @for (error of fields.value().errors(); track error) { + {{ error.message }} + } + } +
+
+ + + @if (fields.label().touched()) { + @for (error of fields.label().errors(); track error) { + {{ error.message }} + } + } +
+
+ + Family + + + + + + +
+
+
+ @if (error(); as message) { +
+

+ Equipment type could not be saved +

+

{{ message }}

+
+ } +
+ + +
+
diff --git a/src/app/features/organization/features/equipments/ui/forms/equipment-type-form/equipment-type-form.component.ts b/src/app/features/organization/features/equipments/ui/forms/equipment-type-form/equipment-type-form.component.ts new file mode 100644 index 000000000..3f8dc0b89 --- /dev/null +++ b/src/app/features/organization/features/equipments/ui/forms/equipment-type-form/equipment-type-form.component.ts @@ -0,0 +1,332 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + input, + output, + signal, + untracked, + type InputSignal, + type OutputEmitterRef, + type Signal, + type WritableSignal, +} from '@angular/core'; +import { + disabled, + form, + FormField, + maxLength, + validate, + type FieldTree, +} from '@angular/forms/signals'; +import type { + CreateEquipmentTypeInput, + EquipmentTypeOutput, +} from '@features/organization/features/equipments/models'; +import { HlmAlertImports } from '@shared/ui/alert'; +import { HlmButton } from '@shared/ui/button'; +import { HlmFieldImports } from '@shared/ui/field'; +import { HlmInput } from '@shared/ui/input'; +import { HlmSpinner } from '@shared/ui/spinner'; +import { HlmToggleGroupImports } from '@shared/ui/toggle-group'; + +/** + * Class EquipmentTypeForm + * @class EquipmentTypeForm + * + * @description + * Creates and edits catalogue descriptors without owning transport or navigation. + */ +@Component({ + selector: 'app-equipment-type-form', + templateUrl: './equipment-type-form.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + FormField, + HlmButton, + HlmInput, + HlmSpinner, + ...HlmFieldImports, + ...HlmAlertImports, + ...HlmToggleGroupImports, + ], +}) +export class EquipmentTypeForm { + //#region Properties + /** + * Property entry + * @readonly + * + * @description + * Reviewed descriptor; null opens a fresh creation draft. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly entry: InputSignal = + input(null); + + /** + * Property pending + * @readonly + * + * @description + * Disables editing and duplicate submissions during a command. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly pending: InputSignal = input(false); + + /** + * Property available + * @readonly + * + * @description + * Editing availability is independent of an accepted command, so offline drafts can be cancelled. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly available: InputSignal = input(true); + + /** + * Property error + * @readonly + * + * @description + * Rejected command detail displayed without resetting input. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly error: InputSignal = input(null); + + /** + * Property submitted + * @readonly + * + * @description + * Validated fields; the page adds the reviewed revision when editing. + * + * @access public + * @since unreleased + * + * @type {OutputEmitterRef} + */ + public readonly submitted: OutputEmitterRef = + output(); + + /** + * Property cancelled + * @readonly + * + * @description + * Requests cancellation; the page confirms discarding dirty input. + * + * @access public + * @since unreleased + * + * @type {OutputEmitterRef} + */ + public readonly cancelled: OutputEmitterRef = output(); + + /** + * Property dirtyChanged + * @readonly + * + * @description + * Reports actual field dirtiness for the host's dismissal protection. + * + * @access public + * @since unreleased + * + * @type {OutputEmitterRef} + */ + public readonly dirtyChanged: OutputEmitterRef = output(); + + /** + * Property draft + * @readonly + * + * @description + * Local validated catalogue draft, retained after rejection or revision refresh. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal} + */ + protected readonly draft: WritableSignal = + signal({ value: '', label: '', family: 'fire' }); + + /** + * Property fields + * @readonly + * + * @description + * Server-aligned code and label rules with an immutable edit code. + * + * @access protected + * @since unreleased + * + * @type {FieldTree} + */ + protected readonly fields: FieldTree = form(this.draft, (path) => { + disabled(path, () => this.pending() || !this.available()); + disabled(path.value, () => this.entry() !== null); + validate(path.value, ({ value }) => + /^[a-z][a-z0-9_]{0,31}$/.test(value()) + ? null + : { + kind: 'code', + message: $localize`:@@equipment.catalog.form.codeInvalid:Use 1–32 lowercase letters, digits or underscores, starting with a letter.`, + }, + ); + maxLength(path.value, 32); + validate(path.label, ({ value }) => + value().trim() + ? null + : { + kind: 'required', + message: $localize`:@@equipment.catalog.form.labelRequired:Enter an equipment type name.`, + }, + ); + maxLength(path.label, 100, { + message: $localize`:@@equipment.catalog.form.labelTooLong:Use at most 100 characters for the name.`, + }); + validate(path.family, ({ value }) => + ['fire', 'safety', 'other'].includes(value()) + ? null + : { + kind: 'family', + message: $localize`:@@equipment.catalog.form.familyRequired:Choose an equipment family.`, + }, + ); + }); + + /** + * Property identity + * @readonly + * + * @description + * Revision refreshes retain the draft while selecting another code resets it. + * + * @access private + * @since unreleased + * + * @type {Signal} + */ + private readonly identity: Signal = computed(() => this.entry()?.value ?? null); + + /** + * Property submitLabel + * @readonly + * + * @description + * Action caption reflects create or edit intent. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly submitLabel: Signal = computed(() => + this.entry() + ? $localize`:@@equipment.catalog.form.save:Save type` + : $localize`:@@equipment.catalog.form.create:Create type`, + ); + //#endregion + + //#region Constructor + /** + * Constructor + * @constructor + * + * @description + * Seeds record identity once and exposes real dirty state to the hosting editor. + * + * @access public + * @since unreleased + */ + public constructor() { + effect(() => { + this.identity(); + untracked(() => { + const entry = this.entry(); + this.fields().reset( + entry + ? { value: entry.value, label: entry.label, family: entry.family } + : { value: '', label: '', family: 'fire' }, + ); + }); + }); + effect(() => { + const dirty = this.fields().dirty(); + untracked(() => this.dirtyChanged.emit(dirty)); + }); + } + //#endregion + + //#region Methods + /** + * Method familyChanged + * @method familyChanged + * + * @description + * Connects the installed toggle group to the typed Signal Form field. + * + * @access protected + * @since unreleased + * + * @param {unknown} value - Native toggle selection. + * + * @returns {void} + */ + protected familyChanged(value: unknown): void { + if ( + this.pending() || + !this.available() || + (value !== 'fire' && value !== 'safety' && value !== 'other') + ) + return; + this.draft.update((draft) => ({ ...draft, family: value })); + this.fields.family().markAsDirty(); + } + + /** + * Method submit + * @method submit + * + * @description + * Emits validated input while leaving unsuccessful drafts intact. + * + * @access protected + * @since unreleased + * + * @param {Event} event - Native form submission. + * + * @returns {void} + */ + protected submit(event: Event): void { + event.preventDefault(); + this.fields().markAsTouched(); + if (this.pending() || !this.available() || this.fields().invalid()) return; + const draft = this.draft(); + this.submitted.emit({ + ...draft, + value: this.entry()?.value ?? draft.value, + label: draft.label.trim(), + }); + } + //#endregion +} diff --git a/src/app/features/organization/features/equipments/ui/forms/equipment-type-form/index.ts b/src/app/features/organization/features/equipments/ui/forms/equipment-type-form/index.ts new file mode 100644 index 000000000..5b5c3b102 --- /dev/null +++ b/src/app/features/organization/features/equipments/ui/forms/equipment-type-form/index.ts @@ -0,0 +1 @@ +export { EquipmentTypeForm } from './equipment-type-form.component'; diff --git a/src/app/features/organization/features/equipments/ui/forms/equipment-type-form/testing/equipment-type-form.component.spec.ts b/src/app/features/organization/features/equipments/ui/forms/equipment-type-form/testing/equipment-type-form.component.spec.ts new file mode 100644 index 000000000..f3fa50b1e --- /dev/null +++ b/src/app/features/organization/features/equipments/ui/forms/equipment-type-form/testing/equipment-type-form.component.spec.ts @@ -0,0 +1,137 @@ +import { TestBed, type ComponentFixture } from '@angular/core/testing'; +import type { + CreateEquipmentTypeInput, + EquipmentTypeOutput, +} from '@features/organization/features/equipments/models'; +import { EquipmentTypeForm } from '../equipment-type-form.component'; + +describe('EquipmentTypeForm', () => { + let fixture: ComponentFixture; + let writes: CreateEquipmentTypeInput[]; + const entry: EquipmentTypeOutput = { + '@id': '/api/organizations/org/types/blanket', + '@type': 'EquipmentType', + value: 'blanket', + label: 'Blanket', + family: 'fire', + archived: false, + revision: 2, + }; + + const field = (id: string): HTMLInputElement => { + const element = (fixture.nativeElement as HTMLElement).querySelector( + `#equipment-type-${id}`, + ); + if (!element) throw new Error(`Missing input ${id}`); + return element; + }; + const fill = async (id: string, value: string): Promise => { + const input = field(id); + input.value = value; + input.dispatchEvent(new Event('input', { bubbles: true })); + await fixture.whenStable(); + }; + const submit = async (): Promise => { + const element = (fixture.nativeElement as HTMLElement).querySelector('form'); + if (!element) throw new Error('Missing type form'); + element.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); + await fixture.whenStable(); + }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ imports: [EquipmentTypeForm] }).compileComponents(); + fixture = TestBed.createComponent(EquipmentTypeForm); + writes = []; + fixture.componentInstance.submitted.subscribe((input) => writes.push(input)); + await fixture.whenStable(); + }); + + it('emits a valid custom code, trimmed name and selected family', async () => { + await fill('code', 'gas_sensor'); + await fill('label', ' Gas sensor '); + fixture.componentInstance['familyChanged']('safety'); + await submit(); + expect(writes).toEqual([{ value: 'gas_sensor', label: 'Gas sensor', family: 'safety' }]); + }); + + it.each(['', 'UPPERCASE', '1sensor', 'smoke-detector', 'x'.repeat(33)])( + 'rejects invalid permanent code %s', + async (code) => { + await fill('code', code); + await fill('label', 'Sensor'); + await submit(); + expect(writes).toEqual([]); + expect((fixture.nativeElement as HTMLElement).textContent).toContain('Use 1–32'); + }, + ); + + it.each([' ', 'x'.repeat(101)])( + 'rejects blank or overlong names without dropping the draft', + async (label) => { + await fill('code', 'sensor'); + await fill('label', label); + await submit(); + expect(writes).toEqual([]); + expect(field('label').value).toBe(label); + }, + ); + + it('disables immutable code editing and preserves a rejected draft when reviewing a new revision', async () => { + fixture.componentRef.setInput('entry', entry); + await fixture.whenStable(); + expect(field('code').disabled).toBe(true); + await fill('label', 'Draft blanket'); + fixture.componentRef.setInput('error', 'Revision changed.'); + fixture.componentRef.setInput('entry', { ...entry, label: 'Another user label', revision: 3 }); + await fixture.whenStable(); + expect(field('label').value).toBe('Draft blanket'); + expect((fixture.nativeElement as HTMLElement).textContent).toContain('Revision changed.'); + await submit(); + expect(writes).toEqual([{ value: entry.value, label: 'Draft blanket', family: 'fire' }]); + }); + + it('blocks duplicate writes while leaving entered fields intact', async () => { + await fill('code', 'sensor'); + await fill('label', 'Sensor'); + fixture.componentRef.setInput('pending', true); + await fixture.whenStable(); + expect(field('label').disabled).toBe(true); + await submit(); + expect(writes).toEqual([]); + expect(field('label').value).toBe('Sensor'); + }); + + it('allows explicit cancellation of an offline draft while disabling new commands', async () => { + const cancelled = vi.fn(); + fixture.componentInstance.cancelled.subscribe(cancelled); + await fill('code', 'sensor'); + await fill('label', 'Sensor'); + fixture.componentRef.setInput('available', false); + await fixture.whenStable(); + await submit(); + expect(writes).toEqual([]); + const button = (fixture.nativeElement as HTMLElement).querySelector( + 'button[variant="outline"]', + ); + if (!button) throw new Error('Missing cancel action'); + expect(button.disabled).toBe(false); + button.click(); + expect(cancelled).toHaveBeenCalledTimes(1); + expect(field('label').value).toBe('Sensor'); + }); + + it('resets fields only when selecting a different catalogue identity', async () => { + fixture.componentRef.setInput('entry', entry); + await fixture.whenStable(); + await fill('label', 'Draft'); + fixture.componentRef.setInput('entry', { + ...entry, + value: 'camera', + label: 'Camera', + family: 'safety', + }); + await fixture.whenStable(); + expect(field('code').value).toBe('camera'); + expect(field('label').value).toBe('Camera'); + }); +}); diff --git a/src/app/features/organization/features/equipments/ui/pages/equipment-detail-page/equipment-detail-page.component.html b/src/app/features/organization/features/equipments/ui/pages/equipment-detail-page/equipment-detail-page.component.html index 054511cb7..36f710435 100644 --- a/src/app/features/organization/features/equipments/ui/pages/equipment-detail-page/equipment-detail-page.component.html +++ b/src/app/features/organization/features/equipments/ui/pages/equipment-detail-page/equipment-detail-page.component.html @@ -1,12 +1,7 @@
- @if (activeEquipmentStore.selectedEquipment(); as equipment) { + @if (selectedEquipment(); as equipment) {
- -
+
+
+
+ Declared operational status +
+
+
+
+
+ Control follow-up +
+
+ +
+ @if (equipment.controlNextDueAt; as dueAt) { +
+ Next control: + {{ dueAt | appOrgDate: 'date' : regionalFormatting() }} +
+ } + @if ( + canReadInspections() && inspectionSummaryStore.queryData()?.equipmentId === equipment.id + ) { + @if (inspectionSummaryStore.queryData()?.lastInspectionId; as inspectionId) { +
+ Open the last control +
+ } + } +
+
+
+ Open anomalies +
+ @if (!canReadInspections()) { +
+ Inspection access required +
+ } @else if (inspectionSummaryStore.isQueryLoading()) { +
+ } @else if (inspectionSummaryStore.queryHasError()) { +
+ Unavailable +
+
+ +
+ } @else if (inspectionSummaryStore.queryData(); as summary) { + @if (summary.equipmentId === equipment.id) { +
+ {{ + summary.openAnomalies + }} +
+ } + } @else { +
+ Unavailable +
+ } + @if (canReportDefect()) { +
+ Report a defect +
+ } + @if (canRequestRepair() && equipment.status !== 'decommissioned') { +
+ Request a repair +
+ } +
+
+
+ @if (facility().type === 'site' && !facility().parentFacilityId) { + + + @if (facility().customerId) { + + } @else { + No customer + } + + + @if (isEditing('customerId')) { + + } + + + }
+ * @since unreleased */ @Component({ selector: 'app-facility-information-panel', imports: [ RouterLink, InplaceField, + CustomerPicker, + CustomerLabel, FacilityMapPickerDialog, HlmButton, HlmInput, @@ -109,6 +115,80 @@ const LEVEL_INDEX_BOUNDS: readonly [number, number] = [-100, 200]; changeDetection: ChangeDetectionStrategy.OnPush, }) export class FacilityInformationPanel { + //#region Properties + /** + * Property customerDraft + * @readonly + * + * @description + * Signal Forms draft for the optional root-site customer. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal<{ customerId: string }>} + */ + protected readonly customerDraft: WritableSignal<{ customerId: string }> = signal({ + customerId: '', + }); + /** + * Property customerForm + * @readonly + * + * @description + * Native customer field owns its dirty state and pending disablement. + * + * @access protected + * @since unreleased + * + * @type {FieldTree<{ customerId: string }>} + */ + protected readonly customerForm: FieldTree<{ customerId: string }> = form( + this.customerDraft, + (path) => { + disabled(path, () => this.isSaving('customerId')); + }, + ); + //#endregion + + //#region Methods + /** + * Method saveCustomer + * @method + * + * @description + * Saves a customer assignment only after the inline editor's explicit confirmation. + * + * @access protected + * @since unreleased + * + * @returns {void} No return value. + */ + protected saveCustomer(): void { + if (this.isSaving('customerId')) return; + this.detailsChanged.emit({ customerId: this.customerDraft().customerId || null }); + } + /** + * Method customerEditing + * @method + * + * @description + * Seeds the selected internal customer when its editor opens. + * + * @access protected + * @since unreleased + * + * @param {boolean} open - Value supplied by the owning customer workflow. + * + * @returns {void} No return value. + */ + protected customerEditing(open: boolean): void { + if (open) this.customerForm().reset({ customerId: this.facility().customerId ?? '' }); + this.editTargetChanged.emit(open ? 'customerId' : null); + } + + //#endregion + //#region Inputs /** * Property facility @@ -947,7 +1027,7 @@ export class FacilityInformationPanel { ) return null; - return this.facility()[target]; + return this.facility()[target] ?? null; } //#endregion } diff --git a/src/app/features/organization/features/facilities/ui/forms/facility-create-form/facility-create-form.component.html b/src/app/features/organization/features/facilities/ui/forms/facility-create-form/facility-create-form.component.html index 8ba853a07..74b75e5f0 100644 --- a/src/app/features/organization/features/facilities/ui/forms/facility-create-form/facility-create-form.component.html +++ b/src/app/features/organization/features/facilities/ui/forms/facility-create-form/facility-create-form.component.html @@ -88,6 +88,22 @@

{{ errorTitle }}

} + @if (model().type === 'site' && organizationId()) { + + + +

+ Optional. Assign a customer to this root site, or manage it directly as an operator. +

+
+ } + @if (model().type === 'floor') {

+ * @since unreleased */ @Component({ selector: 'app-facility-create-sheet', @@ -54,6 +52,20 @@ import { FacilityCreateForm } from '../../forms/facility-create-form'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class FacilityCreateSheet { + /** + * Property organizationId + * @readonly + * + * @description + * Organization passed to the root-site customer chooser. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly organizationId: InputSignal = input(''); + /** * Property parentCallState * @readonly diff --git a/src/app/features/organization/features/inspections/FEATURE.md b/src/app/features/organization/features/inspections/FEATURE.md index f0659932b..8afcd68c2 100644 --- a/src/app/features/organization/features/inspections/FEATURE.md +++ b/src/app/features/organization/features/inspections/FEATURE.md @@ -297,3 +297,7 @@ These narrow entry points are published to the named consumers. `app` denotes th | Entry point | Consumers | | ------------------------------------- | -------------- | | `ui/components/inspection-status-tag` | `organization` | +| `ui/dataviews/non-conformity-list` | `organization` | + +Organization's Parc reuses NonConformityList in read-only register mode with source-inspection +links. The owning inspection detail retains all finding updates and approval workflows. diff --git a/src/app/features/organization/features/inspections/ui/dataviews/non-conformity-list/non-conformity-list.component.html b/src/app/features/organization/features/inspections/ui/dataviews/non-conformity-list/non-conformity-list.component.html index 0d9fa84fc..81b35b2dc 100644 --- a/src/app/features/organization/features/inspections/ui/dataviews/non-conformity-list/non-conformity-list.component.html +++ b/src/app/features/organization/features/inspections/ui/dataviews/non-conformity-list/non-conformity-list.component.html @@ -53,6 +53,17 @@ data-testid="non-conformity-row" [attr.data-non-conformity-id]="nc.id" > + @if (showSourceLinks()) { + Open the source inspection + }

} + */ + public readonly showSourceLinks: InputSignal = input(false); + //#endregion + + //#region Methods + /** + * Method sourceInspectionLabelOf + * @method + * + * @description + * Names a source inspection using the finding description, without ambiguous repeated links. + * + * @access protected + * @since unreleased + * + * @param {NonConformityOutput} nc - Value supplied by the owning park workflow. + * + * @returns {string} Result consumed by the owning park workflow. + */ + protected sourceInspectionLabelOf(nc: NonConformityOutput): string { + return $localize`:@@park.anomalies.openInspectionLabel:Open source inspection for ${truncateDescription(nc.description)}:description:`; + } + + //#endregion + //#region Inputs /** * Property nonConformities diff --git a/src/app/features/organization/features/interventions/FEATURE.md b/src/app/features/organization/features/interventions/FEATURE.md index ae6a2de8d..56523c9f7 100644 --- a/src/app/features/organization/features/interventions/FEATURE.md +++ b/src/app/features/organization/features/interventions/FEATURE.md @@ -38,6 +38,54 @@ This subfeature is responsible for: - intervention publication and issue checks, - intervention offline persistence and outbox replay. +Equipment maintenance, repair and replacement tasks record an explicit performed-at instant, +outcome and work description through the existing revision-checked task command. Results stay +staged until publication; unsuccessful maintenance remains open. Local projection never derives +the execution date from synchronization or task status. Inspection controls keep their owning +Inspection result resource. Published report downloads use the server's closure snapshot; +unpublished downloads are explicitly labelled drafts with live data. + +Replacement completion also attaches the confirmed published successor returned by Equipment. +The original remains the result's equipment target. The UI reads the reciprocal replacement link, +checks organization, retired original and published successor, and sends its canonical resource +with the result in the same revision-checked command. Missing proof blocks success; failed attempts +remain recordable. Opening the existing equipment dossier and refreshing preserve the work draft. +Account, organization, session and permission changes cancel the proof read and discard its data. + +## Parts used and stock declarations + +The Overview tab discloses Parts used locally. The mounted form survives collapse, refresh and +interaction-mode changes. The task context links a declaration to its work item and equipment; +responsible members and participants can also declare for the intervention as a whole. A task's +named assignee can select their task. Inventory read, consumption and intervention execution +permissions remain separate; the API rechecks membership and resource scope on every declaration. +Published interventions accept authorized late physical facts while keeping their closure snapshot. +The internal-cost link requires `organization.maintenance_cost.read` and opens the Cost feature; +ordinary stock histories and device snapshots contain no financial amounts. + +Approved sibling dependency: Inventory publishes its `models`, `data-access` and +`ui/components` concern barrels. Intervention composes `InventoryConsumptionPanel`, supplies +authorized parts, warehouses and server declarations, and owns transport and durable outbox writes. +Inventory owns stock confirmation, reconciliation and exact quantitative contracts. Preparation +drains every authorized catalog and declaration page, with stable totals and a 10,000-row bound. +Missing pages, changing totals, permission errors and obsolete sessions never create a partial +snapshot labelled complete. A retained catalog describes references rather than available stock. + +Account/organization/intervention metadata stores versioned stock snapshots in the existing +IndexedDB schema. An individual receipt cannot imply a complete catalog or declaration history. +Snapshot and receipt mutations are serialized, retain every acknowledged declaration identity, +and never regress confirmed stock to a stale pending response. Account, session, organization and +loaded permission fences apply at every asynchronous boundary before exposure or persistence. + +Each consumption keeps one immutable `clientOperationId`, physical timestamp and exact quantity +string through retries. The form is acknowledged only after the outbox transaction succeeds. +Device storage failure retains submitted input, protects browser departure and route navigation, +and blocks publication until the same declaration is saved. Every durable queued operation also +participates in the existing publication preflight and service-worker update guard. Server receipt +persistence completes before its queue row is removed; a failed local receipt save keeps the same +operation pending for idempotent replay. `received_pending` means the physical fact was received +but still needs stock reconciliation, separately from device persistence and synchronization. + ## Effort, time and workload Intervention owns nullable task estimates, explicitly reassessed remaining effort, @@ -59,8 +107,19 @@ The server rechecks every planning mutation transactionally. A confirmation retr the captured command and revision with the exact presented token, not edited form state. Time journals and drafts are account-scoped IndexedDB records separate from operational -workspace snapshots. Authorized journals are prefetched with saved workspaces; missing -offline history remains explicitly unknown. Stable entry IDs and independent revisions +workspace snapshots. Journal entries use explicit bounded server pages; embedded revisions contain +only the current version. Correction history loads only on expansion and follows exclusive revision +cursors with independent request states and retry. Preparation caches only the first authorized +entry page. Cached pages preserve their page metadata and beneficiary audience, never replace a +legacy complete snapshot, and never imply that uncached pages or full revision history are available. +Beneficiary-only reads explicitly request `ownOnly=true`, including background preparation, +so a later server permission grant cannot broaden a page cached under captured restricted access. +Versioned page keys exclude older pages whose audience was inferred from stale capabilities. +Restricted pages containing another beneficiary are rejected as a whole; complete legacy snapshots +are filtered before pagination. Cache compatibility never discards drafts, queued intentions or +their physical stored revisions. +Missing offline pages remain explicitly unknown; earlier revisions require reconnection. +Stable entry IDs and independent revisions make replay idempotent. Drafts survive failed writes; failed device persistence retains the latest input in memory and guards dismissal. A native browser departure warning is requested only after local persistence fails and the latest draft differs from its confirmed device snapshot; @@ -88,6 +147,11 @@ the update waiting. No persistent-storage permission is requested automatically. The index is `/organizations/:organizationId/interventions`; List, Board, Calendar and Recurrences use the existing `?view=` values in one page. Legacy paths redirect. `?create=1` is consumed once in the browser and requires planning permission. +Equipment dossiers may add `targetEquipment=`, `workAction=inspection|maintenance|repair|replacement` +and optional `siteContext=`. These validated hints seed intervention preparation; +after creation the detail opens the existing task form with that equipment and action. The planner +confirms the normal task command. Existing matching open work is revealed instead of duplicated. +The detail consumes these hints only in the browser; they never authorize cross-organization access. Blank and Template creation preserve separate drafts and commands; duplication prefills a new draft rather than copying the source lifecycle. @@ -124,6 +188,15 @@ Offline repositories, prefetch, replay and the device-global sync widget retain organization, intervention and authentication-session fencing. Stores expose named request states and typed consequence events; concurrent rows retain independent failures. +Successful preparation also reads Equipment's complete authorized type catalog through its public +`EquipmentTypeService.listAll`. `saveEquipmentCatalog` persists it only for the saved workspace's +account, organization and intervention, using the existing metadata store. Ordinary workspace +mutations retain that catalog; deleting an intervention removes only its own catalog metadata. +Restoration seeds the page's public `EquipmentTypeCatalogStore` only under the same authenticated +account and organization with currently loaded equipment-read permission. Revocation and scope +changes clear visible descriptors. Archived entries remain readable for history; legacy snapshots +without a catalog retain their existing behavior. Existing planning catalog search APIs stay compatible. + See the [intervention state reference](../../../../../../docs/guides/interventions-and-offline.md#intervention-state-reference) for details and rationale. ## Published Contracts diff --git a/src/app/features/organization/features/interventions/constants/intervention-outbox-label.constants.ts b/src/app/features/organization/features/interventions/constants/intervention-outbox-label.constants.ts index c191a7577..8e17cdd5a 100644 --- a/src/app/features/organization/features/interventions/constants/intervention-outbox-label.constants.ts +++ b/src/app/features/organization/features/interventions/constants/intervention-outbox-label.constants.ts @@ -7,7 +7,6 @@ import type { InterventionOutboxType } from '@features/organization/features/int * What each queued operation is called when the sync popover lists it. A field * agent who has checked twelve items and written three comments offline needs * to read what is waiting, not a count of seventeen. - * * Verb-object in sentence case, the same grammar as the action that queued it, * so a line in the queue reads as the thing the agent did. * @@ -16,6 +15,7 @@ import type { InterventionOutboxType } from '@features/organization/features/int * @type {Readonly>} */ export const INTERVENTION_OUTBOX_LABEL: Readonly> = { + 'inventory-consumption.declare': $localize`:@@intervention.sync.op.inventoryConsumption:Parts used`, 'time-entry.create': $localize`:@@intervention.sync.op.timeCreate:Time recorded`, 'time-entry.correct': $localize`:@@intervention.sync.op.timeCorrect:Time correction`, 'time-entry.cancel': $localize`:@@intervention.sync.op.timeCancel:Time cancellation`, diff --git a/src/app/features/organization/features/interventions/data-access/index.ts b/src/app/features/organization/features/interventions/data-access/index.ts index be9f01c65..c8b653246 100644 --- a/src/app/features/organization/features/interventions/data-access/index.ts +++ b/src/app/features/organization/features/interventions/data-access/index.ts @@ -1,4 +1,5 @@ /** + * @description * Interventions data-access public exports. */ export { InterventionService } from './services/intervention/intervention.service'; @@ -13,3 +14,4 @@ export { } from './services/intervention-offline/constants'; export { InterventionTimeService } from './services/intervention-time/intervention-time.service'; export { InterventionTimeRepository } from './services/intervention-offline/intervention-time.repository'; +export { InterventionInventoryRepository } from './services/intervention-offline/intervention-inventory.repository'; diff --git a/src/app/features/organization/features/interventions/data-access/services/intervention-offline/intervention-inventory.repository.ts b/src/app/features/organization/features/interventions/data-access/services/intervention-offline/intervention-inventory.repository.ts new file mode 100644 index 000000000..9cb5c0dbc --- /dev/null +++ b/src/app/features/organization/features/interventions/data-access/services/intervention-offline/intervention-inventory.repository.ts @@ -0,0 +1,289 @@ +import { inject, Service } from '@angular/core'; +import type { + InterventionInventorySnapshot, + InterventionOutput, +} from '@features/organization/features/interventions/models'; +import type { InventoryConsumptionOutput } from '@features/organization/features/inventory/models'; +import { InterventionDatabaseService } from './intervention-database.service'; + +/** + * Class InterventionInventoryRepository + * @class InterventionInventoryRepository + * + * @description + * Persists ordinary stock references and declaration receipts in existing account-scoped metadata. + * Financial amounts never enter this workspace snapshot. + */ +@Service() +export class InterventionInventoryRepository { + //#region Properties + /** + * Property database + * @readonly + * + * @description + * Existing durable store whose owner binding also purges data on session termination. + * + * @access private + * @since unreleased + * + * @type {InterventionDatabaseService} + */ + private readonly database: InterventionDatabaseService = inject(InterventionDatabaseService); + + //#endregion + + //#region Methods + /** + * Method load + * @method load + * + * @description + * Restores only the current owner's correctly scoped snapshot without inventing an empty catalog. + * + * @access public + * @since unreleased + * + * @param {string} organizationId - Owning organization UUID. + * @param {string} interventionId - Saved workspace UUID. + * @param {() => boolean} isCurrent - Captured session, organization and permission guard. + * + * @returns {Promise} Authorized retained snapshot. + */ + public async load( + organizationId: string, + interventionId: string, + isCurrent: () => boolean, + ): Promise { + const owner = this.database.currentOwnerId(); + if (!owner || !isCurrent()) return null; + await this.database.ensureOwnerBound(); + if (!isCurrent() || owner !== this.database.currentOwnerId()) return null; + const snapshot = await this.database.get( + 'metadata', + this.key(owner, organizationId, interventionId), + ); + if (!isCurrent() || owner !== this.database.currentOwnerId()) return null; + return this.scopedSnapshot(snapshot, owner, organizationId, interventionId); + } + + /** + * Method save + * @method save + * + * @description + * Commits a complete snapshot only while its already-saved workspace and captured owner match. + * + * @access public + * @since unreleased + * + * @param {InterventionInventorySnapshot} snapshot - Scoped complete or receipt-only snapshot. + * @param {() => boolean} isCurrent - Captured authorization and session guard. + * + * @returns {Promise} Resolves only after durable persistence is confirmed. + */ + public async save( + snapshot: InterventionInventorySnapshot, + isCurrent: () => boolean, + ): Promise { + return this.persist(snapshot, isCurrent); + } + + /** + * Method persist + * @method persist + * + * @description + * Reads ownership, workspace and retained facts in the same transaction as the merged write. + * IndexedDB serializes mutations across repository instances and browser tabs. + * + * @access private + * @since unreleased + * + * @param {InterventionInventorySnapshot} snapshot - Scoped references and declarations to merge + * with retained accepted facts. + * @param {() => boolean} isCurrent - Captured authorization guard checked before and after + * persistence. + * @param {boolean} receiptOnly - Preserve retained catalogs and coverage for an individual + * receipt. + * + * @returns {Promise} Resolves only after the owner-scoped metadata write completes. + */ + private async persist( + snapshot: InterventionInventorySnapshot, + isCurrent: () => boolean, + receiptOnly = false, + ): Promise { + this.assertCurrent(snapshot.accountId, isCurrent); + await this.database.ensureOwnerBound(); + this.assertCurrent(snapshot.accountId, isCurrent); + const key = this.key(snapshot.accountId, snapshot.organizationId, snapshot.interventionId); + await this.database.updateTransaction( + { + metadata: ['ownerUserId', key], + interventions: [snapshot.interventionId], + }, + (values) => { + this.assertCurrent(snapshot.accountId, isCurrent); + if (values['metadata']?.['ownerUserId'] !== snapshot.accountId) + throw new DOMException('Inventory snapshot ownership changed.', 'AbortError'); + const workspace = values['interventions']?.[ + snapshot.interventionId + ] as InterventionOutput | null; + if (workspace?.organization !== `/api/organizations/${snapshot.organizationId}`) + throw new Error( + $localize`:@@intervention.inventory.prepareRequired:Save this intervention on the device before preparing its stock references.`, + ); + const previous = this.scopedSnapshot( + values['metadata']?.[key], + snapshot.accountId, + snapshot.organizationId, + snapshot.interventionId, + ); + const declarations = new Map(); + for (const item of previous?.declarations ?? []) declarations.set(item.id, item); + for (const item of snapshot.declarations) { + const retained = declarations.get(item.id); + if (retained?.status === 'confirmed' && item.status === 'received_pending') continue; + declarations.set(item.id, item); + } + const retainedReferences = receiptOnly && previous ? previous : snapshot; + return { + metadata: [ + { + key, + value: { + ...retainedReferences, + capturedAt: snapshot.capturedAt, + declarations: [...declarations.values()], + }, + }, + ], + }; + }, + () => isCurrent() && snapshot.accountId === this.database.currentOwnerId(), + ); + this.assertCurrent(snapshot.accountId, isCurrent); + } + + /** + * Method saveReceipt + * @method saveReceipt + * + * @description + * Retains an accepted server fact before removing its idempotent local operation. + * A receipt alone cannot claim that either the catalog or server history is complete. + * + * @access public + * @since unreleased + * + * @param {string} organizationId - Replay organization. + * @param {string} accountId - Account captured before the server write. + * @param {InventoryConsumptionOutput} receipt - Accepted quantitative declaration. + * @param {() => boolean} isCurrent - Captured replay session and organization guard. + * + * @returns {Promise} Durable server acknowledgment. + */ + public async saveReceipt( + organizationId: string, + accountId: string, + receipt: InventoryConsumptionOutput, + isCurrent: () => boolean, + ): Promise { + return this.persist( + { + version: 1, + accountId, + organizationId, + interventionId: receipt.interventionId, + capturedAt: new Date().toISOString(), + catalogComplete: false, + declarationsComplete: false, + parts: [], + warehouses: [], + declarations: [receipt], + }, + isCurrent, + true, + ); + } + + /** + * Method scopedSnapshot + * @method scopedSnapshot + * + * @description + * Accepts only the expected version and scope with usable reference and declaration arrays. + * + * @access private + * @since unreleased + * + * @param {unknown} value - Persisted metadata, including missing or malformed records. + * @param {string} owner - Account owner. + * @param {string} organizationId - Organization UUID. + * @param {string} interventionId - Workspace UUID. + * + * @returns {InterventionInventorySnapshot | null} Valid scoped snapshot or null. + */ + private scopedSnapshot( + value: unknown, + owner: string, + organizationId: string, + interventionId: string, + ): InterventionInventorySnapshot | null { + const snapshot = value as InterventionInventorySnapshot | null | undefined; + if ( + !snapshot || + snapshot.version !== 1 || + snapshot.accountId !== owner || + snapshot.organizationId !== organizationId || + snapshot.interventionId !== interventionId || + !Array.isArray(snapshot.parts) || + !Array.isArray(snapshot.warehouses) || + !Array.isArray(snapshot.declarations) + ) + return null; + return snapshot; + } + + /** + * Method key + * @method key + * + * @description + * Separates references and facts for each account, organization and workspace. + * + * @access private + * @since unreleased + * + * @param {string} owner - Account owner. + * @param {string} organizationId - Organization UUID. + * @param {string} interventionId - Workspace UUID. + * + * @returns {string} Metadata key. + */ + private key(owner: string, organizationId: string, interventionId: string): string { + return `inventorySnapshot:${owner}:${organizationId}:${interventionId}`; + } + + /** + * Method assertCurrent + * @method assertCurrent + * + * @description + * Rejects obsolete persistence instead of acknowledging an operation under a replacement account. + * + * @access private + * @since unreleased + * + * @param {string} owner - Captured account owner. + * @param {() => boolean} isCurrent - Additional session and organization fence. + * + * @returns {void} Throws on obsolete scope. + */ + private assertCurrent(owner: string, isCurrent: () => boolean): void { + if (!isCurrent() || owner !== this.database.currentOwnerId()) + throw new DOMException('Inventory snapshot ownership changed.', 'AbortError'); + } + //#endregion +} diff --git a/src/app/features/organization/features/interventions/data-access/services/intervention-offline/intervention-offline.service.ts b/src/app/features/organization/features/interventions/data-access/services/intervention-offline/intervention-offline.service.ts index 839b15e42..be5d958ff 100644 --- a/src/app/features/organization/features/interventions/data-access/services/intervention-offline/intervention-offline.service.ts +++ b/src/app/features/organization/features/interventions/data-access/services/intervention-offline/intervention-offline.service.ts @@ -1,4 +1,5 @@ import { inject, Service, type Signal } from '@angular/core'; +import type { EquipmentTypeOutput } from '@features/organization/features/equipments/models'; import type { PublicationTracking, InterventionChangeOutput, @@ -10,6 +11,7 @@ import type { InterventionOutput, InterventionWorkItemOutput, } from '@features/organization/features/interventions/models'; +import type { DeclareInventoryConsumptionInput } from '@features/organization/features/inventory/models'; import { InterventionDatabaseService } from './intervention-database.service'; import { InterventionOutboxRepository } from './intervention-outbox.repository'; import { InterventionWorkspaceRepository } from './intervention-workspace.repository'; @@ -209,6 +211,37 @@ export class InterventionOfflineService { } //#region Workspace + /** + * Method saveEquipmentCatalog + * @method saveEquipmentCatalog + * + * @description + * Persists the complete authorized catalogue independently of workspace mutation merges. + * + * @access public + * @since unreleased + * + * @param {string} interventionId - Persisted field workspace identifier. + * @param {string} organizationId - Owning organization identifier. + * @param {readonly EquipmentTypeOutput[]} entries - Complete authorized catalogue. + * @param {string | null} expectedOwner - Account captured before the request began. + * + * @returns {Promise} Catalogue persistence completion. + */ + public saveEquipmentCatalog( + interventionId: string, + organizationId: string, + entries: readonly EquipmentTypeOutput[], + expectedOwner: string | null, + ): Promise { + return this.workspace.saveEquipmentCatalog( + interventionId, + organizationId, + entries, + expectedOwner, + ); + } + /** * Method saveWorkspace * @method saveWorkspace @@ -264,13 +297,7 @@ export class InterventionOfflineService { * * @param {string} interventionId - intervention Id value. * - * @returns {Promise<{ - * intervention: InterventionOutput; - * workItems: readonly InterventionWorkItemOutput[]; - * changes: readonly InterventionChangeOutput[]; - * issues: readonly InterventionIssueOutput[]; - * } | null>} - * Result of the get workspace operation. + * @returns {Promise} Account-scoped saved workspace. */ public getWorkspace(interventionId: string): Promise { return this.workspace.getWorkspace(interventionId); @@ -361,6 +388,32 @@ export class InterventionOfflineService { return this.outbox.queueMany(interventionId, entries); } + /** + * Method queueInventoryConsumption + * @method queueInventoryConsumption + * + * @description + * Persists an immutable physical declaration before the UI acknowledges and clears it. + * + * @access public + * @since unreleased + * + * @param {string} organizationId - Owning organization. + * @param {DeclareInventoryConsumptionInput} input - Captured exact intention. + * @param {string} actorId - Captured account owner. + * @param {() => boolean} isCurrent - Organization, session and permission guard. + * + * @returns {Promise} Durable outbox acceptance. + */ + public queueInventoryConsumption( + organizationId: string, + input: DeclareInventoryConsumptionInput, + actorId: string, + isCurrent: () => boolean, + ): Promise { + return this.outbox.queueInventoryConsumption(organizationId, input, actorId, isCurrent); + } + /** * Method listOutbox * @method listOutbox @@ -602,6 +655,13 @@ export class InterventionOfflineService { const interventionIri = `/api/interventions/${interventionId}`; await Promise.all([ this.database.remove('interventions', interventionId), + this.database.removeWhere( + 'metadata', + (_record, key) => + typeof key === 'string' && + (key.startsWith('equipmentCatalog:') || key.startsWith('inventorySnapshot:')) && + key.endsWith(`:${interventionId}`), + ), this.database.removeWhere( 'timeJournals', (record) => record.interventionId === interventionId, diff --git a/src/app/features/organization/features/interventions/data-access/services/intervention-offline/intervention-outbox.repository.ts b/src/app/features/organization/features/interventions/data-access/services/intervention-offline/intervention-outbox.repository.ts index 00914d878..0027cdb2c 100644 --- a/src/app/features/organization/features/interventions/data-access/services/intervention-offline/intervention-outbox.repository.ts +++ b/src/app/features/organization/features/interventions/data-access/services/intervention-offline/intervention-outbox.repository.ts @@ -11,6 +11,7 @@ import type { InterventionOutboxQueueEntry, InterventionOutboxType, } from '@features/organization/features/interventions/models'; +import type { DeclareInventoryConsumptionInput } from '@features/organization/features/inventory/models'; import type { WorkloadAssessment } from '@features/organization/features/workload/models'; import { InterventionDatabaseService } from './intervention-database.service'; @@ -149,6 +150,20 @@ export class InterventionOutboxRepository { */ private lastQueuedAt: number = 0; + /** + * Property consumptionQueues + * @readonly + * + * @description + * Serializes repeat declarations locally so one immutable physical intention has one durable row. + * + * @access private + * @since unreleased + * + * @type {Map>} + */ + private readonly consumptionQueues: Map> = new Map(); + /** * Property hasUnsyncedChanges * @readonly @@ -284,6 +299,124 @@ export class InterventionOutboxRepository { this.pendingOps.update((count: number) => count + 1); } + /** + * Method queueInventoryConsumption + * @method queueInventoryConsumption + * + * @description + * Durably records a physical declaration once, retaining its operation UUID and original + * timestamp. Identical retries reactivate its row; an identity reused for another fact is + * rejected. + * + * @access public + * @since unreleased + * + * @param {string} organizationId - Organization owning the saved workspace. + * @param {DeclareInventoryConsumptionInput} input - Immutable physical intention. + * @param {string} actorId - Account captured before submission. + * @param {() => boolean} scopeCurrent - Additional organization and permission fence. + * + * @returns {Promise} Resolves only after persistence; callers may then clear the draft. + */ + public queueInventoryConsumption( + organizationId: string, + input: DeclareInventoryConsumptionInput, + actorId: string, + scopeCurrent: () => boolean, + ): Promise { + const mutationCurrent = this.captureMutationContext(); + const isCurrent = (): boolean => + mutationCurrent() && scopeCurrent() && actorId === this.database.currentOwnerId(); + const key = `inventory:${input.clientOperationId}`; + const preceding = this.consumptionQueues.get(key) ?? Promise.resolve(); + const write: Promise = preceding + .catch(() => undefined) + .then(async () => { + this.assertCurrent(isCurrent); + await this.database.ensureOwnerBound(); + this.assertCurrent(isCurrent); + const workspace = await this.database.get<{ readonly organization: string }>( + 'interventions', + input.interventionId, + ); + this.assertCurrent(isCurrent); + if (workspace?.organization !== `/api/organizations/${organizationId}`) + throw new Error( + $localize`:@@intervention.inventory.queueWorkspaceRequired:This intervention must be saved on this device before a consumption can be queued. Retry preparing the workspace.`, + ); + const existing = await this.database.get('outbox', key); + this.assertCurrent(isCurrent); + if (existing) { + if ( + existing.type !== 'inventory-consumption.declare' || + existing.interventionId !== input.interventionId || + existing.payload.actorId !== actorId || + this.consumptionFingerprint(existing.payload) !== this.consumptionFingerprint(input) + ) + throw new Error( + $localize`:@@intervention.inventory.operationConflict:This declaration identifier already belongs to another physical fact. Keep the original declaration and review it before continuing.`, + ); + if (existing.status === 'conflict') + throw new Error( + $localize`:@@intervention.inventory.reviewConflict:Review the conflicted declaration before retrying it.`, + ); + if (existing.status === 'failed') { + await this.retryOutbox(existing.id); + this.assertCurrent(isCurrent); + } + return; + } + const queuedAt = Math.max(Date.now(), this.lastQueuedAt + 1); + this.lastQueuedAt = queuedAt; + const operation: InterventionOutboxOperationFor<'inventory-consumption.declare'> = { + id: key, + interventionId: input.interventionId, + type: 'inventory-consumption.declare', + payload: { ...input, actorId, clientId: input.clientOperationId }, + createdAt: new Date(queuedAt).toISOString(), + status: 'pending', + error: null, + }; + await this.database.put('outbox', key, operation, isCurrent); + this.assertCurrent(isCurrent); + this.unsynced.set(true); + this.pending.set(true); + this.pendingOps.update((count: number) => count + 1); + }); + const settled = write.finally(() => { + if (this.consumptionQueues.get(key) === settled) this.consumptionQueues.delete(key); + }); + this.consumptionQueues.set(key, settled); + return settled; + } + + /** + * Method consumptionFingerprint + * @method consumptionFingerprint + * + * @description + * Compares only immutable declaration fields; technical queue identity never changes a fact. + * + * @access private + * @since unreleased + * + * @param {DeclareInventoryConsumptionInput} input - Physical declaration. + * + * @returns {string} Stable field-order-independent comparison. + */ + private consumptionFingerprint(input: DeclareInventoryConsumptionInput): string { + return JSON.stringify([ + input.clientOperationId, + input.partId, + input.warehouseId, + input.quantity, + input.interventionId, + input.workItemId ?? null, + input.equipmentId ?? null, + input.occurredAt, + ]); + } + /** * Method queueMany * @method queueMany diff --git a/src/app/features/organization/features/interventions/data-access/services/intervention-offline/intervention-time.repository.ts b/src/app/features/organization/features/interventions/data-access/services/intervention-offline/intervention-time.repository.ts index c33fa79bd..7d6c6c18b 100644 --- a/src/app/features/organization/features/interventions/data-access/services/intervention-offline/intervention-time.repository.ts +++ b/src/app/features/organization/features/interventions/data-access/services/intervention-offline/intervention-time.repository.ts @@ -2,6 +2,8 @@ import { inject, Service } from '@angular/core'; import type { InterventionTimeDraft, InterventionTimeEntry, + InterventionTimeScope, + InterventionTimeJournalPage, } from '@features/organization/features/interventions/models'; import { InterventionDatabaseService } from './intervention-database.service'; import type { @@ -47,6 +49,7 @@ export class InterventionTimeRepository { * * @param {string} interventionId - Owning intervention. * @param {string} workItemId - Task identifier. + * * @returns {Promise} Cached entries, or unknown. */ public async readJournal( @@ -68,19 +71,115 @@ export class InterventionTimeRepository { * * @description * Saves server history without applying or consuming queued corrections. + * Restricted pages must contain only their beneficiary's entries; partial filtering would + * invalidate server counts and continuation. * * @access public * @since 1.0.0 * * @param {InterventionTimeRecord} record - Scoped authorized snapshot. * @param {string | null} owner - Account captured before the request began. + * * @returns {Promise} */ public async saveJournal(record: InterventionTimeRecord, owner: string | null): Promise { if (!owner || this.database.currentOwnerId() !== owner) return; await this.database.ensureOwnerBound(); if (this.database.currentOwnerId() !== owner) return; - await this.database.put('timeJournals', record.workItemId, record); + if (record.pagination && !record.audience) return; + if ( + record.pagination && + record.audience?.startsWith('member:') && + record.entries.some((entry) => `member:${entry.memberId}` !== record.audience) + ) + throw new Error('The journal page contains entries outside its authorized audience.'); + const key = record.pagination + ? `page:v2:${record.workItemId}:${record.audience}:${record.pagination.itemsPerPage}:${record.pagination.page}` + : record.workItemId; + await this.database.put( + 'timeJournals', + key, + record, + () => this.database.currentOwnerId() === owner, + ); + } + + /** + * Method readJournalPage + * @method readJournalPage + * + * @description + * Reads one audience-bound page without overwriting or claiming a complete device snapshot. + * Legacy complete journals remain readable only as the current beneficiary's filtered history. + * Earlier page keys cannot prove that the request was beneficiary-restricted and are ignored. + * + * @access public + * @since unreleased + * + * @param {InterventionTimeScope} scope - Current authorized task and beneficiary visibility. + * @param {number} page - Positive requested journal page. + * @param {number} itemsPerPage - Maximum rows on the requested page. + * + * @returns {Promise} Cached page or explicit unknown history. + */ + public async readJournalPage( + scope: InterventionTimeScope, + page = 1, + itemsPerPage = 30, + ): Promise { + const owner = this.database.currentOwnerId(); + if (!owner) return null; + await this.database.ensureOwnerBound(); + if (this.database.currentOwnerId() !== owner) return null; + const audience = scope.manageOthers === true ? 'all' : `member:${scope.actorId}`; + const key = `page:v2:${scope.workItemId}:${audience}:${itemsPerPage}:${page}`; + const record = await this.database.get('timeJournals', key); + if (this.database.currentOwnerId() !== owner) return null; + if ( + record?.interventionId === scope.interventionId && + record.workItemId === scope.workItemId && + record.audience === audience && + record.pagination?.page === page && + record.pagination.itemsPerPage === itemsPerPage + ) + return scope.manageOthers !== true && + record.entries.some((entry) => entry.memberId !== scope.actorId) + ? null + : { ...record.pagination, entries: record.entries }; + if (scope.manageOthers === true) return null; + const legacy = await this.database.get( + 'timeJournals', + scope.workItemId, + ); + if ( + this.database.currentOwnerId() !== owner || + legacy?.interventionId !== scope.interventionId || + legacy.pagination + ) + return null; + const entries = legacy.entries + .filter((entry) => entry.memberId === scope.actorId) + .toSorted( + (left, right) => + right.workedOn.localeCompare(left.workedOn) || left.id.localeCompare(right.id), + ); + if (page > 1 && (page - 1) * itemsPerPage >= entries.length) return null; + return { + page, + itemsPerPage, + totalItems: entries.length, + nextPage: page * itemsPerPage < entries.length ? page + 1 : null, + entries: entries.slice((page - 1) * itemsPerPage, page * itemsPerPage).map((entry) => + Object.assign({}, entry, { + versions: entry.versions + .filter((version) => version.revision === entry.revision) + .slice(0, 1), + totalVersions: entry.totalVersions ?? entry.revision, + nextBeforeRevision: + entry.nextBeforeRevision ?? (entry.revision > 1 ? entry.revision : null), + }), + ), + }; } /** @@ -95,6 +194,7 @@ export class InterventionTimeRepository { * * @param {string} interventionId - Owning intervention. * @param {string} workItemId - Task identifier. + * * @returns {Promise} Saved draft. */ public async readDraft( @@ -122,6 +222,7 @@ export class InterventionTimeRepository { * * @param {InterventionTimeDraftRecord} record - Scoped draft. * @param {string | null} owner - Captured account. + * * @returns {Promise} */ public async saveDraft(record: InterventionTimeDraftRecord, owner: string | null): Promise { @@ -143,6 +244,7 @@ export class InterventionTimeRepository { * * @param {string} workItemId - Task identifier. * @param {string | null} owner - Captured account. + * * @returns {Promise} */ public async clearDraft(workItemId: string, owner: string | null): Promise { diff --git a/src/app/features/organization/features/interventions/data-access/services/intervention-offline/intervention-workspace.repository.ts b/src/app/features/organization/features/interventions/data-access/services/intervention-offline/intervention-workspace.repository.ts index 29dcdfebf..5dc8325cc 100644 --- a/src/app/features/organization/features/interventions/data-access/services/intervention-offline/intervention-workspace.repository.ts +++ b/src/app/features/organization/features/interventions/data-access/services/intervention-offline/intervention-workspace.repository.ts @@ -1,9 +1,11 @@ import { inject, Service } from '@angular/core'; +import type { EquipmentTypeOutput } from '@features/organization/features/equipments/models'; import type { InterventionChangeOutput, InterventionIssueOutput, InterventionOutput, InterventionWorkItemOutput, + InterventionEquipmentCatalogSnapshot, } from '@features/organization/features/interventions/models'; import { InterventionDatabaseService } from './intervention-database.service'; import type { @@ -45,6 +47,56 @@ export class InterventionWorkspaceRepository { //#endregion //#region Methods + /** + * Method saveEquipmentCatalog + * @method saveEquipmentCatalog + * + * @description + * Caches a complete authorized catalogue only while its requesting account owns the workspace. + * + * @access public + * @since unreleased + * + * @param {string} interventionId - Persisted field workspace identifier. + * @param {string} organizationId - Owning organization identifier. + * @param {readonly EquipmentTypeOutput[]} entries - Complete authorized catalogue. + * @param {string | null} expectedOwner - Account captured before the catalogue request. + * + * @returns {Promise} Settles after persistence or abandonment of a stale response. + */ + public async saveEquipmentCatalog( + interventionId: string, + organizationId: string, + entries: readonly EquipmentTypeOutput[], + expectedOwner: string | null, + ): Promise { + const isCurrent = (): boolean => + expectedOwner !== null && expectedOwner === this.database.currentOwnerId(); + if (!isCurrent() || !expectedOwner) return; + await this.database.ensureOwnerBound(); + if (!isCurrent()) return; + const intervention = await this.database.get( + 'interventions', + interventionId, + ); + if (!isCurrent() || intervention?.organization !== `/api/organizations/${organizationId}`) + return; + const snapshot: InterventionEquipmentCatalogSnapshot = { + version: 1, + accountId: expectedOwner, + organizationId, + capturedAt: new Date().toISOString(), + entries, + }; + await this.database.put( + 'metadata', + `equipmentCatalog:${expectedOwner}:${organizationId}:${interventionId}`, + snapshot, + isCurrent, + ); + if (!isCurrent()) return; + } + /** * Method saveWorkspace * @method saveWorkspace @@ -136,30 +188,43 @@ export class InterventionWorkspaceRepository { * * @param {string} interventionId - intervention Id value. * - * @returns {Promise<{ - * intervention: InterventionOutput; - * workItems: readonly InterventionWorkItemOutput[]; - * changes: readonly InterventionChangeOutput[]; - * issues: readonly InterventionIssueOutput[]; - * } | null>} - * Result of the get workspace operation. + * @returns {Promise} Account-scoped saved workspace. */ public async getWorkspace(interventionId: string): Promise { + const owner = this.database.currentOwnerId(); + if (!owner) return null; await this.database.ensureOwnerBound(); + if (owner !== this.database.currentOwnerId()) return null; const intervention = await this.database.get( 'interventions', interventionId, ); - if (!intervention) return null; + if (!intervention || owner !== this.database.currentOwnerId()) return null; const interventionIri = `/api/interventions/${interventionId}`; - const [workItems, changes, resources] = await Promise.all([ + const organizationId = intervention.organization.match( + /^\/api\/organizations\/([^/?#]+)$/, + )?.[1]; + const [workItems, changes, resources, catalog] = await Promise.all([ this.database.getAll('workItems'), this.database.getAll('changes'), this.database.getAll('resources'), + organizationId + ? this.database.get( + 'metadata', + `equipmentCatalog:${owner}:${organizationId}:${interventionId}`, + ) + : Promise.resolve(null), ]); + if (owner !== this.database.currentOwnerId()) return null; return { intervention, + ...(catalog?.version === 1 && + catalog.accountId === owner && + catalog.organizationId === organizationId && + Array.isArray(catalog.entries) + ? { equipmentCatalog: catalog } + : {}), workItems: workItems.filter((item) => item.intervention === interventionIri), changes: changes.filter((change) => change.intervention === interventionIri), issues: resources diff --git a/src/app/features/organization/features/interventions/data-access/services/intervention-offline/models/intervention-time-record.interface.ts b/src/app/features/organization/features/interventions/data-access/services/intervention-offline/models/intervention-time-record.interface.ts index ac661b0c3..37c89b83e 100644 --- a/src/app/features/organization/features/interventions/data-access/services/intervention-offline/models/intervention-time-record.interface.ts +++ b/src/app/features/organization/features/interventions/data-access/services/intervention-offline/models/intervention-time-record.interface.ts @@ -1,6 +1,7 @@ import type { InterventionTimeDraft, InterventionTimeEntry, + InterventionTimeJournalPage, } from '@features/organization/features/interventions/models'; /** @@ -54,6 +55,32 @@ export interface InterventionTimeRecord { * @type {readonly InterventionTimeEntry[]} */ readonly entries: readonly InterventionTimeEntry[]; + + /** + * Property pagination + * @readonly + * + * @description + * Identifies a bounded cached page; absent only on legacy complete journal snapshots. + * + * @access public + * + * @type {Omit} + */ + readonly pagination?: Omit; + + /** + * Property audience + * @readonly + * + * @description + * Identifies broad manager pages or the beneficiary whose restricted page was authorized. + * + * @access public + * + * @type {string} + */ + readonly audience?: string; } /** diff --git a/src/app/features/organization/features/interventions/data-access/services/intervention-offline/models/intervention-workspace-snapshot.interface.ts b/src/app/features/organization/features/interventions/data-access/services/intervention-offline/models/intervention-workspace-snapshot.interface.ts index 2afc661a7..b47374a5c 100644 --- a/src/app/features/organization/features/interventions/data-access/services/intervention-offline/models/intervention-workspace-snapshot.interface.ts +++ b/src/app/features/organization/features/interventions/data-access/services/intervention-offline/models/intervention-workspace-snapshot.interface.ts @@ -3,6 +3,7 @@ import type { InterventionIssueOutput, InterventionOutput, InterventionWorkItemOutput, + InterventionEquipmentCatalogSnapshot, } from '@features/organization/features/interventions/models'; /** @@ -13,6 +14,20 @@ import type { * Offline view of an intervention and its loaded work items, changes, and readiness issues. */ export interface InterventionWorkspaceSnapshot { + /** + * Property equipmentCatalog + * @readonly + * + * @description + * Complete equipment catalogue authorized for this workspace owner and organization. + * Historical snapshots without catalogue metadata remain readable. + * + * @access public + * + * @type {InterventionEquipmentCatalogSnapshot | undefined} + */ + readonly equipmentCatalog?: InterventionEquipmentCatalogSnapshot; + /** * Property intervention * @readonly diff --git a/src/app/features/organization/features/interventions/data-access/services/intervention-offline/testing/intervention-inventory.repository.spec.ts b/src/app/features/organization/features/interventions/data-access/services/intervention-offline/testing/intervention-inventory.repository.spec.ts new file mode 100644 index 000000000..b778c88c5 --- /dev/null +++ b/src/app/features/organization/features/interventions/data-access/services/intervention-offline/testing/intervention-inventory.repository.spec.ts @@ -0,0 +1,710 @@ +import { Injector, PLATFORM_ID, runInInjectionContext } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { IndexedDbService, type IndexedDbSchema } from '@core/indexed-db'; +import type { InterventionInventorySnapshot } from '@features/organization/features/interventions/models'; +import type { + InventoryConsumptionOutput, + InventoryPartOutput, + InventoryWarehouseOutput, +} from '@features/organization/features/inventory/models'; +import { InterventionDatabaseService } from '../intervention-database.service'; +import { InterventionInventoryRepository } from '../intervention-inventory.repository'; + +const metadataKey = 'inventorySnapshot:account-1:org-1:intervention-1'; +const part: InventoryPartOutput = { + '@id': '/api/organizations/org-1/inventory-parts/part-1', + '@type': 'InventoryPart', + id: 'part-1', + code: 'SEAL', + label: 'Safety seal', + unit: 'piece', + kind: 'part', + archived: false, +}; +const warehouse: InventoryWarehouseOutput = { + '@id': '/api/organizations/org-1/inventory-warehouses/warehouse-1', + '@type': 'InventoryWarehouse', + id: 'warehouse-1', + code: 'VAN', + name: 'Technician van', + archived: false, +}; +const receipt: InventoryConsumptionOutput = { + '@id': '/api/organizations/org-1/inventory-consumptions/receipt-1', + '@type': 'InventoryConsumption', + id: 'receipt-1', + partId: 'part-1', + warehouseId: 'warehouse-1', + quantity: '2.000000', + interventionId: 'intervention-1', + actorId: 'account-1', + occurredAt: '2026-10-06T10:00:00Z', + status: 'received_pending', + reason: 'insufficient_stock', + movementId: null, + late: false, + replayed: false, +}; +const completeSnapshot: InterventionInventorySnapshot = { + version: 1, + accountId: 'account-1', + organizationId: 'org-1', + interventionId: 'intervention-1', + capturedAt: '2026-10-06T09:00:00Z', + catalogComplete: true, + declarationsComplete: true, + parts: [part], + warehouses: [warehouse], + declarations: [], +}; + +/** + * Function deferred + * + * @description + * Holds a persistence boundary until the test changes its captured authorization or owner. + * + * @template Value - Value returned by the controlled read or write. + * + * @returns {{ promise: Promise; resolve: (value: Value) => void }} Controlled completion. + */ +function deferred(): { + promise: Promise; + resolve: (value: Value) => void; +} { + let resolve!: (value: Value) => void; + const promise = new Promise((complete) => { + resolve = complete; + }); + return { promise, resolve }; +} + +class BrowserDatabase extends IndexedDbService { + protected readonly schema: IndexedDbSchema = { + name: 'inventory-repository-test', + version: 1, + storeNames: ['metadata', 'interventions'], + ownerStoreName: 'metadata', + }; +} + +/** Native request events over shared stores; overlapping transactions wait for the earlier commit. */ +function sharedIndexedDb(stores: ReadonlyMap>) { + let tail: Promise = Promise.resolve(); + let nextRead: ReturnType | null = null; + let nextCommit: ReturnType | null = null; + const transactions = vi.fn((names: string | readonly string[]) => { + const selected = typeof names === 'string' ? [names] : names; + const finished = deferred(); + const earlier = tail; + tail = finished.promise; + const readBoundary = nextRead; + const commitBoundary = nextCommit; + nextRead = null; + nextCommit = null; + let aborted = false; + const operations: (() => void)[] = []; + const view = new Map>(); + const transaction = Object.assign(new EventTarget(), { + error: null, + abort: () => { + aborted = true; + transaction.dispatchEvent(new Event('abort')); + finished.resolve(); + }, + objectStore: (name: string) => { + if (!selected.includes(name)) + throw new DOMException('Store is not locked.', 'NotFoundError'); + return { + get: (key: string) => { + const request = Object.assign(new EventTarget(), { + result: undefined as unknown, + error: null, + }); + operations.push(() => { + request.result = view.get(name)?.get(key); + request.dispatchEvent(new Event('success')); + }); + return request; + }, + put: (value: unknown, key: string) => { + operations.push(() => { + view.get(name)?.set(key, value); + }); + }, + }; + }, + }); + void earlier.then(async () => { + readBoundary?.reached.resolve(); + await readBoundary?.release.promise; + if (aborted) return; + for (const name of selected) view.set(name, new Map(stores.get(name))); + for (let index = 0; index < operations.length; index += 1) { + if (aborted) return; + operations[index]?.(); + } + commitBoundary?.reached.resolve(); + await commitBoundary?.release.promise; + if (aborted) return; + for (const [name, records] of view) { + const target = stores.get(name); + target?.clear(); + for (const [key, value] of records) target?.set(key, value); + } + transaction.dispatchEvent(new Event('complete')); + finished.resolve(); + }); + return transaction; + }); + const open = vi.fn(() => { + const database = Object.assign(new EventTarget(), { + transaction: transactions, + close: vi.fn(), + }); + const request = Object.assign(new EventTarget(), { result: database, error: null }); + queueMicrotask(() => request.dispatchEvent(new Event('success'))); + return request; + }); + function boundary() { + return { reached: deferred(), release: deferred() }; + } + return { + open, + transactions, + blockRead: () => { + nextRead = boundary(); + return nextRead; + }, + blockCommit: () => { + nextCommit = boundary(); + return nextCommit; + }, + }; +} + +describe('InterventionInventoryRepository', () => { + const metadata = new Map(); + const workspaces = new Map(); + let owner: string | null = 'account-1'; + let authorized = true; + const isCurrent = (): boolean => authorized && owner === 'account-1'; + const database = { + currentOwnerId: vi.fn<() => string | null>(), + ensureOwnerBound: vi.fn<() => Promise>(), + get: vi.fn<(collection: string, key: string) => Promise>(), + updateTransaction: vi.fn(), + }; + let repository: InterventionInventoryRepository; + let storage: ReturnType; + + beforeEach(() => { + owner = 'account-1'; + authorized = true; + metadata.clear(); + metadata.set('ownerUserId', 'account-1'); + workspaces.clear(); + workspaces.set('intervention-1', { organization: '/api/organizations/org-1' }); + vi.resetAllMocks(); + database.currentOwnerId.mockImplementation(() => owner); + database.ensureOwnerBound.mockResolvedValue(undefined); + database.get.mockImplementation(async (collection, key) => { + const entries = collection === 'metadata' ? metadata : workspaces; + return entries.get(key) ?? null; + }); + storage = sharedIndexedDb( + new Map([ + ['metadata', metadata], + ['interventions', workspaces], + ]), + ); + vi.stubGlobal('indexedDB', { open: storage.open }); + TestBed.configureTestingModule({ + providers: [ + InterventionInventoryRepository, + { provide: PLATFORM_ID, useValue: 'browser' }, + { provide: InterventionDatabaseService, useValue: database }, + ], + }); + const native = TestBed.runInInjectionContext(() => new BrowserDatabase()); + database.updateTransaction.mockImplementation((...args) => native.updateTransaction(...args)); + repository = TestBed.inject(InterventionInventoryRepository); + }); + + afterEach(() => { + TestBed.resetTestingModule(); + vi.unstubAllGlobals(); + }); + + function secondRepository(): InterventionInventoryRepository { + const native = TestBed.runInInjectionContext(() => new BrowserDatabase()); + const injector = Injector.create({ + providers: [ + { + provide: InterventionDatabaseService, + useValue: { + ...database, + updateTransaction: ( + ...args: Parameters + ) => native.updateTransaction(...args), + }, + }, + ], + }); + return runInInjectionContext(injector, () => new InterventionInventoryRepository()); + } + + it('loads the authorized account, organization and intervention key without changing capture age', async () => { + metadata.set(metadataKey, completeSnapshot); + + await expect(repository.load('org-1', 'intervention-1', isCurrent)).resolves.toBe( + completeSnapshot, + ); + + expect(database.get).toHaveBeenCalledWith('metadata', metadataKey); + expect(database.updateTransaction).not.toHaveBeenCalled(); + expect(completeSnapshot.capturedAt).toBe('2026-10-06T09:00:00Z'); + }); + + it.each(['missing owner', 'revoked permission'])( + 'does not bind or read a snapshot with %s', + async (reason) => { + if (reason === 'missing owner') owner = null; + else authorized = false; + + await expect(repository.load('org-1', 'intervention-1', isCurrent)).resolves.toBeNull(); + + expect(database.ensureOwnerBound).not.toHaveBeenCalled(); + expect(database.get).not.toHaveBeenCalled(); + }, + ); + + it('does not fall back to another account, organization or intervention key', async () => { + metadata.set('inventorySnapshot:account-2:org-1:intervention-1', completeSnapshot); + metadata.set('inventorySnapshot:account-1:org-2:intervention-1', completeSnapshot); + metadata.set('inventorySnapshot:account-1:org-1:intervention-2', completeSnapshot); + + await expect(repository.load('org-1', 'intervention-1', isCurrent)).resolves.toBeNull(); + + expect(database.get).toHaveBeenCalledTimes(1); + expect(database.get).toHaveBeenCalledWith('metadata', metadataKey); + }); + + it.each([ + ['account', { accountId: 'account-2' }], + ['organization', { organizationId: 'org-2' }], + ['intervention', { interventionId: 'intervention-2' }], + ['schema version', { version: 2 }], + ['parts', { parts: null }], + ['warehouses', { warehouses: {} }], + ['declarations', { declarations: undefined }], + ])('rejects stored metadata with a malformed or mismatching %s', async (_field, mismatch) => { + metadata.set(metadataKey, { ...completeSnapshot, ...mismatch }); + + await expect(repository.load('org-1', 'intervention-1', isCurrent)).resolves.toBeNull(); + }); + + it.each(['owner', 'permission'])( + 'discards a load when %s changes during binding', + async (change) => { + const binding = deferred(); + database.ensureOwnerBound.mockReturnValueOnce(binding.promise); + const loaded = repository.load('org-1', 'intervention-1', isCurrent); + + if (change === 'owner') owner = 'account-2'; + else authorized = false; + binding.resolve(); + + await expect(loaded).resolves.toBeNull(); + expect(database.get).not.toHaveBeenCalled(); + }, + ); + + it.each(['owner', 'permission'])( + 'discards a load when %s changes during the read', + async (change) => { + const read = deferred(); + database.get.mockReturnValueOnce(read.promise); + const loaded = repository.load('org-1', 'intervention-1', isCurrent); + await vi.waitFor(() => expect(database.get).toHaveBeenCalled()); + + if (change === 'owner') owner = 'account-2'; + else authorized = false; + read.resolve(completeSnapshot); + + await expect(loaded).resolves.toBeNull(); + }, + ); + + it('saves under the captured workspace account in one guarded native transaction', async () => { + await repository.save(completeSnapshot, isCurrent); + + expect(metadata.get(metadataKey)).toEqual(completeSnapshot); + expect(database.updateTransaction).toHaveBeenCalledWith( + { metadata: ['ownerUserId', metadataKey], interventions: ['intervention-1'] }, + expect.any(Function), + expect.any(Function), + ); + expect(storage.transactions).toHaveBeenCalledExactlyOnceWith( + ['metadata', 'interventions'], + 'readwrite', + ); + expect(database.get).not.toHaveBeenCalled(); + }); + + it.each(['missing workspace', 'other organization'])( + 'does not save references for a %s', + async (reason) => { + if (reason === 'missing workspace') workspaces.clear(); + else workspaces.set('intervention-1', { organization: '/api/organizations/org-2' }); + + await expect(repository.save(completeSnapshot, isCurrent)).rejects.toThrow( + 'Save this intervention on the device before preparing its stock references.', + ); + expect(metadata.has(metadataKey)).toBe(false); + }, + ); + + it.each(['owner', 'permission'])( + 'rejects a save when its %s is already obsolete', + async (change) => { + if (change === 'owner') owner = 'account-2'; + else authorized = false; + + await expect(repository.save(completeSnapshot, isCurrent)).rejects.toMatchObject({ + name: 'AbortError', + }); + expect(database.ensureOwnerBound).not.toHaveBeenCalled(); + expect(database.updateTransaction).not.toHaveBeenCalled(); + }, + ); + + it.each(['owner', 'permission'])( + 'does not acknowledge a save after %s changes during binding', + async (change) => { + const binding = deferred(); + database.ensureOwnerBound.mockReturnValueOnce(binding.promise); + const saved = repository.save(completeSnapshot, isCurrent); + const rejected = expect(saved).rejects.toMatchObject({ name: 'AbortError' }); + if (change === 'owner') owner = 'account-2'; + else authorized = false; + binding.resolve(); + + await rejected; + expect(database.updateTransaction).not.toHaveBeenCalled(); + }, + ); + + it.each(['owner', 'permission'])( + 'does not write after %s changes while transaction reads are pending', + async (change) => { + const read = storage.blockRead(); + const saved = repository.save(completeSnapshot, isCurrent); + const rejected = expect(saved).rejects.toMatchObject({ name: 'AbortError' }); + await read.reached.promise; + if (change === 'owner') owner = 'account-2'; + else authorized = false; + read.release.resolve(); + + await rejected; + expect(metadata.has(metadataKey)).toBe(false); + }, + ); + + it('checks the stored owner marker after another connection changes accounts', async () => { + const read = storage.blockRead(); + const saved = repository.save(completeSnapshot, () => true); + const rejected = expect(saved).rejects.toMatchObject({ name: 'AbortError' }); + await read.reached.promise; + metadata.set('ownerUserId', 'account-2'); + read.release.resolve(); + + await rejected; + expect(metadata.has(metadataKey)).toBe(false); + expect(metadata.get('ownerUserId')).toBe('account-2'); + }); + + it.each(['owner', 'permission'])( + 'does not acknowledge a completed write after %s changes', + async (change) => { + const commit = storage.blockCommit(); + const saved = repository.save(completeSnapshot, isCurrent); + const rejected = expect(saved).rejects.toMatchObject({ name: 'AbortError' }); + await commit.reached.promise; + if (change === 'owner') owner = 'account-2'; + else authorized = false; + commit.release.resolve(); + + await rejected; + }, + ); + + it('propagates storage exhaustion without replacing the retained snapshot', async () => { + metadata.set(metadataKey, completeSnapshot); + const quotaError = new DOMException('Device storage is full.', 'QuotaExceededError'); + database.updateTransaction.mockRejectedValueOnce(quotaError); + + await expect( + repository.save({ ...completeSnapshot, declarations: [receipt] }, isCurrent), + ).rejects.toBe(quotaError); + expect(metadata.get(metadataKey)).toBe(completeSnapshot); + }); + + it('retains a server receipt before resolving its acknowledgment without claiming complete history', async () => { + const commit = storage.blockCommit(); + const acknowledged = vi.fn(); + const saved = repository + .saveReceipt('org-1', 'account-1', receipt, isCurrent) + .then(acknowledged); + await commit.reached.promise; + expect(acknowledged).not.toHaveBeenCalled(); + expect(metadata.has(metadataKey)).toBe(false); + commit.release.resolve(); + await saved; + + expect(acknowledged).toHaveBeenCalledOnce(); + expect(metadata.get(metadataKey)).toEqual({ + version: 1, + accountId: 'account-1', + organizationId: 'org-1', + interventionId: 'intervention-1', + capturedAt: expect.any(String), + catalogComplete: false, + declarationsComplete: false, + parts: [], + warehouses: [], + declarations: [receipt], + }); + }); + + it.each([true, false])( + 'preserves retained references and history coverage (%s) when appending a receipt', + async (complete) => { + const originalReceipt = { ...receipt, id: 'receipt-0' }; + metadata.set(metadataKey, { + ...completeSnapshot, + catalogComplete: complete, + declarationsComplete: complete, + declarations: [originalReceipt], + }); + + await repository.saveReceipt('org-1', 'account-1', receipt, isCurrent); + + expect(metadata.get(metadataKey)).toMatchObject({ + catalogComplete: complete, + declarationsComplete: complete, + parts: [part], + warehouses: [warehouse], + declarations: [originalReceipt, receipt], + }); + }, + ); + + it('keeps one declaration when the same server receipt is received again', async () => { + await repository.saveReceipt('org-1', 'account-1', receipt, isCurrent); + const replayed = { ...receipt, replayed: true }; + await repository.saveReceipt('org-1', 'account-1', replayed, isCurrent); + + expect((await repository.load('org-1', 'intervention-1', isCurrent))?.declarations).toEqual([ + replayed, + ]); + }); + + it('retains a receipt from an independent instance while a full capture is committing', async () => { + const other = secondRepository(); + const commit = storage.blockCommit(); + const captured = repository.save(completeSnapshot, isCurrent); + await commit.reached.promise; + const received = other.saveReceipt('org-1', 'account-1', receipt, isCurrent); + await vi.waitFor(() => expect(storage.transactions).toHaveBeenCalledTimes(2)); + commit.release.resolve(); + await Promise.all([captured, received]); + + expect(await repository.load('org-1', 'intervention-1', isCurrent)).toMatchObject({ + catalogComplete: true, + declarationsComplete: true, + parts: [part], + warehouses: [warehouse], + declarations: [receipt], + }); + }); + + it('keeps an acknowledged pending receipt after outbox removal when an older capture from another instance finishes', async () => { + const other = secondRepository(); + const outbox = new Map([['operation-1', receipt]]); + const commit = storage.blockCommit(); + const received = repository + .saveReceipt('org-1', 'account-1', receipt, isCurrent) + .then(() => outbox.delete('operation-1')); + await commit.reached.promise; + const captured = other.save(completeSnapshot, isCurrent); + await vi.waitFor(() => expect(storage.transactions).toHaveBeenCalledTimes(2)); + expect(outbox.has('operation-1')).toBe(true); + commit.release.resolve(); + await Promise.all([received, captured]); + + expect(outbox.size).toBe(0); + expect(metadata.get(metadataKey)).toMatchObject({ + catalogComplete: true, + declarationsComplete: true, + parts: [part], + warehouses: [warehouse], + declarations: [receipt], + }); + expect(storage.open).toHaveBeenCalledTimes(2); + }); + + it('retains distinct receipts committed by separate instances to the same shared store', async () => { + const other = secondRepository(); + const secondReceipt = { + ...receipt, + '@id': '/api/organizations/org-1/inventory-consumptions/receipt-2', + id: 'receipt-2', + quantity: '1.000000', + }; + const commit = storage.blockCommit(); + const received = repository.saveReceipt('org-1', 'account-1', receipt, isCurrent); + await commit.reached.promise; + const secondReceived = other.saveReceipt('org-1', 'account-1', secondReceipt, isCurrent); + await vi.waitFor(() => expect(storage.transactions).toHaveBeenCalledTimes(2)); + commit.release.resolve(); + await Promise.all([received, secondReceived]); + + expect(metadata.get(metadataKey)).toMatchObject({ + catalogComplete: false, + declarationsComplete: false, + declarations: [receipt, secondReceipt], + }); + }); + + it('updates a retained declaration with confirmed server state without losing other facts', async () => { + const otherReceipt = { ...receipt, id: 'receipt-2' }; + metadata.set(metadataKey, { ...completeSnapshot, declarations: [receipt, otherReceipt] }); + const confirmedReceipt: InventoryConsumptionOutput = { + ...receipt, + status: 'confirmed', + reason: null, + movementId: 'movement-1', + }; + + await repository.save({ ...completeSnapshot, declarations: [confirmedReceipt] }, isCurrent); + + expect((await repository.load('org-1', 'intervention-1', isCurrent))?.declarations).toEqual([ + confirmedReceipt, + otherReceipt, + ]); + }); + + it.each(['full capture', 'replayed receipt'])( + 'does not regress confirmed stock when a stale %s races from an independent instance', + async (source) => { + const other = secondRepository(); + const confirmedReceipt: InventoryConsumptionOutput = { + ...receipt, + status: 'confirmed', + reason: null, + movementId: 'movement-1', + }; + metadata.set(metadataKey, { ...completeSnapshot, declarations: [receipt] }); + const commit = storage.blockCommit(); + const confirmed = repository.save( + { ...completeSnapshot, declarations: [confirmedReceipt] }, + isCurrent, + ); + await commit.reached.promise; + const pending = + source === 'full capture' + ? other.save({ ...completeSnapshot, declarations: [receipt] }, isCurrent) + : other.saveReceipt('org-1', 'account-1', { ...receipt, replayed: true }, isCurrent); + await vi.waitFor(() => expect(storage.transactions).toHaveBeenCalledTimes(2)); + commit.release.resolve(); + await Promise.all([confirmed, pending]); + + expect(metadata.get(metadataKey)).toMatchObject({ declarations: [confirmedReceipt] }); + }, + ); + + it.each([ + ['account', { accountId: 'account-2' }], + ['organization', { organizationId: 'org-2' }], + ['intervention', { interventionId: 'intervention-2' }], + ['schema', { version: 2 }], + ['catalog', { parts: null }], + ['history', { declarations: null }], + ])('does not merge dirty or mismatching %s metadata into a receipt', async (_field, mismatch) => { + metadata.set(metadataKey, { + ...completeSnapshot, + declarations: [{ ...receipt, id: 'unrelated' }], + ...mismatch, + }); + + await repository.saveReceipt('org-1', 'account-1', receipt, isCurrent); + + expect(metadata.get(metadataKey)).toMatchObject({ + accountId: 'account-1', + organizationId: 'org-1', + interventionId: 'intervention-1', + catalogComplete: false, + declarationsComplete: false, + declarations: [receipt], + }); + }); + + it('allows a later receipt after a preceding atomic storage failure', async () => { + const quotaError = new DOMException('Device storage is full.', 'QuotaExceededError'); + database.updateTransaction.mockRejectedValueOnce(quotaError); + await expect(repository.save(completeSnapshot, isCurrent)).rejects.toBe(quotaError); + + await repository.saveReceipt('org-1', 'account-1', receipt, isCurrent); + + expect(metadata.get(metadataKey)).toMatchObject({ declarations: [receipt] }); + }); + + it('does not acknowledge a receipt whose device write exceeds quota', async () => { + const quotaError = new DOMException('Device storage is full.', 'QuotaExceededError'); + database.updateTransaction.mockRejectedValueOnce(quotaError); + + await expect(repository.saveReceipt('org-1', 'account-1', receipt, isCurrent)).rejects.toBe( + quotaError, + ); + expect(metadata.has(metadataKey)).toBe(false); + }); + + it('checks account binding even when the caller guard stays authorized during a load', async () => { + const read = deferred(); + database.get.mockReturnValueOnce(read.promise); + const loaded = repository.load('org-1', 'intervention-1', () => true); + await vi.waitFor(() => expect(database.get).toHaveBeenCalled()); + owner = 'account-2'; + read.resolve(completeSnapshot); + + await expect(loaded).resolves.toBeNull(); + }); + + it.each(['owner', 'permission'])( + 'does not acknowledge a receipt after %s changes during history restoration', + async (change) => { + const read = storage.blockRead(); + const saved = repository.saveReceipt('org-1', 'account-1', receipt, isCurrent); + const rejected = expect(saved).rejects.toMatchObject({ name: 'AbortError' }); + await read.reached.promise; + if (change === 'owner') owner = 'account-2'; + else authorized = false; + read.release.resolve(); + + await rejected; + expect(metadata.has(metadataKey)).toBe(false); + }, + ); + + it('rejects receipt acknowledgment after the account changes even with an authorized caller guard', async () => { + const commit = storage.blockCommit(); + const saved = repository.saveReceipt('org-1', 'account-1', receipt, () => true); + const rejected = expect(saved).rejects.toMatchObject({ name: 'AbortError' }); + await commit.reached.promise; + owner = 'account-2'; + commit.release.resolve(); + + await rejected; + }); +}); diff --git a/src/app/features/organization/features/interventions/data-access/services/intervention-offline/testing/intervention-offline.service.spec.ts b/src/app/features/organization/features/interventions/data-access/services/intervention-offline/testing/intervention-offline.service.spec.ts index bf911c6c0..8b08923ba 100644 --- a/src/app/features/organization/features/interventions/data-access/services/intervention-offline/testing/intervention-offline.service.spec.ts +++ b/src/app/features/organization/features/interventions/data-access/services/intervention-offline/testing/intervention-offline.service.spec.ts @@ -1,5 +1,6 @@ import { signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; +import type { EquipmentTypeOutput } from '@features/organization/features/equipments/models'; import type { InterventionOutput, PublicationTracking, @@ -26,6 +27,7 @@ describe('InterventionOfflineService', () => { listOutbox: vi.fn(), refresh: vi.fn(), }; + const catalogWorkspace = { saveEquipmentCatalog: vi.fn() }; beforeEach(() => { vi.resetAllMocks(); @@ -38,6 +40,7 @@ describe('InterventionOfflineService', () => { database.clearAll.mockResolvedValue(undefined); outbox.listOutbox.mockResolvedValue([]); outbox.refresh.mockResolvedValue(undefined); + catalogWorkspace.saveEquipmentCatalog.mockResolvedValue(undefined); }); /** @@ -53,7 +56,7 @@ describe('InterventionOfflineService', () => { InterventionOfflineService, { provide: InterventionDatabaseService, useValue: database }, { provide: InterventionOutboxRepository, useValue: outbox }, - { provide: InterventionWorkspaceRepository, useValue: {} }, + { provide: InterventionWorkspaceRepository, useValue: catalogWorkspace }, ], }); return TestBed.inject(InterventionOfflineService); @@ -116,6 +119,28 @@ describe('InterventionOfflineService', () => { expect(workspace.saveWorkspace).toHaveBeenCalledOnce(); }); + it('forwards the captured owner and complete catalogue to its persistence repository', async () => { + const service = configurePersistence(); + const entries: readonly EquipmentTypeOutput[] = [ + { + '@id': '/api/organizations/org-1/equipment-types/legacy_pump', + '@type': 'EquipmentType', + value: 'legacy_pump', + label: 'Historical pump', + family: 'fire', + archived: true, + revision: 3, + }, + ]; + await service.saveEquipmentCatalog('intervention-1', 'org-1', entries, 'account-1'); + expect(catalogWorkspace.saveEquipmentCatalog).toHaveBeenCalledExactlyOnceWith( + 'intervention-1', + 'org-1', + entries, + 'account-1', + ); + }); + it('scopes publication recovery metadata to account, organization and intervention', async () => { const service = configurePersistence(); const tracking: PublicationTracking = { @@ -233,7 +258,7 @@ describe('InterventionOfflineService', () => { expect(database.ensureOwnerBound).toHaveBeenCalledOnce(); expect(database.remove).toHaveBeenCalledExactlyOnceWith('interventions', 'intervention-1'); - expect(database.removeWhere).toHaveBeenCalledTimes(stores.length); + expect(database.removeWhere).toHaveBeenCalledTimes(stores.length + 1); for (const store of stores) { expect(records.get(store)).toEqual([ { interventionId: 'intervention-2', intervention: '/api/interventions/intervention-2' }, @@ -242,6 +267,27 @@ describe('InterventionOfflineService', () => { expect(outbox.refresh).toHaveBeenCalledOnce(); }); + it('purges only the selected intervention catalogue metadata and preserves other records', async () => { + const service = configurePersistence(); + let records = [ + 'equipmentCatalog:account-1:org-1:intervention-1', + 'equipmentCatalog:account-1:org-1:intervention-2', + 'publication:account-1:org-1:intervention-1', + 'owner', + ]; + database.removeWhere.mockImplementation( + async (store: string, predicate: (value: unknown, key: IDBValidKey) => boolean) => { + if (store === 'metadata') records = records.filter((key) => !predicate({}, key)); + }, + ); + await service.clearIntervention('intervention-1'); + expect(records).toEqual([ + 'equipmentCatalog:account-1:org-1:intervention-2', + 'publication:account-1:org-1:intervention-1', + 'owner', + ]); + }); + it('does not announce an empty queue when a local purge fails', async () => { const service = configurePersistence(); const failure = new Error('IndexedDB write refused'); diff --git a/src/app/features/organization/features/interventions/data-access/services/intervention-offline/testing/intervention-outbox.repository.spec.ts b/src/app/features/organization/features/interventions/data-access/services/intervention-offline/testing/intervention-outbox.repository.spec.ts index 0f3feba15..f9ce0a510 100644 --- a/src/app/features/organization/features/interventions/data-access/services/intervention-offline/testing/intervention-outbox.repository.spec.ts +++ b/src/app/features/organization/features/interventions/data-access/services/intervention-offline/testing/intervention-outbox.repository.spec.ts @@ -5,6 +5,7 @@ import { EMPTY, Subject } from 'rxjs'; import { USER_IDENTITY_PORT } from '@features/account/ports'; import { AUTH_SESSION_PORT } from '@features/auth/ports'; import type { InterventionOutboxOperation } from '@features/organization/features/interventions/models'; +import type { DeclareInventoryConsumptionInput } from '@features/organization/features/inventory/models'; import { InterventionDatabaseService } from '../intervention-database.service'; import { InterventionOutboxRepository } from '../intervention-outbox.repository'; @@ -12,7 +13,10 @@ import { InterventionOutboxRepository } from '../intervention-outbox.repository' * Minimal in-memory IndexedDB stand-in backing the outbox object store, so the * pending/unsynced signal semantics can be exercised without a real database. */ -function inMemoryDatabase(store: Map): { +function inMemoryDatabase( + store: Map, + workspaceOrganization: string | null = null, +): { browser: boolean; currentOwnerId: ReturnType; ensureOwnerBound: ReturnType; @@ -28,7 +32,13 @@ function inMemoryDatabase(store: Map): { put: vi.fn(async (_collection: string, key: string, value: InterventionOutboxOperation) => { store.set(key, value); }), - get: vi.fn(async (_collection: string, key: string) => store.get(key) ?? null), + get: vi.fn(async (collection: string, key: string) => + collection === 'interventions' + ? workspaceOrganization + ? { organization: workspaceOrganization } + : null + : (store.get(key) ?? null), + ), getAll: vi.fn(async () => [...store.values()]), remove: vi.fn(async (_collection: string, key: string) => { store.delete(key); @@ -57,6 +67,191 @@ function build( } describe('InterventionOutboxRepository', () => { + const consumption: DeclareInventoryConsumptionInput = { + clientOperationId: '11111111-1111-4111-8111-111111111111', + partId: 'part-1', + warehouseId: 'warehouse-1', + quantity: '2.000000', + interventionId: 'intervention-1', + workItemId: 'work-item-1', + equipmentId: 'equipment-1', + occurredAt: '2026-10-06T10:00:00.123Z', + }; + + it('durably queues concurrent identical consumption retries as one stable operation', async () => { + const entries = new Map(); + const database = inMemoryDatabase(entries, '/api/organizations/org-1'); + const repository = build(database); + await Promise.all([ + repository.queueInventoryConsumption('org-1', consumption, 'account', () => true), + repository.queueInventoryConsumption('org-1', { ...consumption }, 'account', () => true), + ]); + + expect(entries.size).toBe(1); + expect([...entries.keys()]).toEqual([`inventory:${consumption.clientOperationId}`]); + expect(entries.get(`inventory:${consumption.clientOperationId}`)).toMatchObject({ + id: `inventory:${consumption.clientOperationId}`, + type: 'inventory-consumption.declare', + interventionId: consumption.interventionId, + payload: { ...consumption, actorId: 'account', clientId: consumption.clientOperationId }, + status: 'pending', + error: null, + }); + expect(database.put).toHaveBeenCalledOnce(); + expect(repository.pendingCount()).toBe(1); + expect(repository.hasUnsyncedChanges()).toBe(true); + }); + + it('does not acknowledge or count a consumption when durable storage exceeds quota', async () => { + const entries = new Map(); + const database = inMemoryDatabase(entries, '/api/organizations/org-1'); + const storageFailure = new DOMException('Device storage is full.', 'QuotaExceededError'); + database.put.mockRejectedValueOnce(storageFailure); + const repository = build(database); + + await expect( + repository.queueInventoryConsumption('org-1', consumption, 'account', () => true), + ).rejects.toBe(storageFailure); + expect(entries.size).toBe(0); + expect(repository.pendingCount()).toBe(0); + expect(repository.hasUnsyncedChanges()).toBe(false); + expect(repository.hasPendingChanges()).toBe(false); + + await repository.queueInventoryConsumption('org-1', consumption, 'account', () => true); + expect(entries.size).toBe(1); + expect(repository.pendingCount()).toBe(1); + expect(entries.get(`inventory:${consumption.clientOperationId}`)?.payload).toMatchObject( + consumption, + ); + }); + + it.each([null, '/api/organizations/org-2'])( + 'refuses a consumption for a missing or differently scoped saved workspace (%s)', + async (savedOrganization) => { + const entries = new Map(); + const database = inMemoryDatabase(entries, savedOrganization); + const repository = build(database); + await expect( + repository.queueInventoryConsumption('org-1', consumption, 'account', () => true), + ).rejects.toThrow('must be saved on this device'); + expect(database.put).not.toHaveBeenCalled(); + expect(repository.pendingCount()).toBe(0); + }, + ); + + it('fences a consumption write when the same account returns in a new session', async () => { + const entries = new Map(); + const database = inMemoryDatabase(entries, '/api/organizations/org-1'); + const revision = signal(1); + let release: (() => void) | undefined; + const pending = new Promise((resolve) => { + release = resolve; + }); + database.put.mockImplementationOnce( + async ( + _collection: string, + key: string, + value: InterventionOutboxOperation, + current: () => boolean, + ) => { + await pending; + if (!current()) throw new DOMException('Session changed.', 'AbortError'); + entries.set(key, value); + }, + ); + const repository = build(database, revision); + const queued = repository.queueInventoryConsumption( + 'org-1', + consumption, + 'account', + () => true, + ); + const rejected = expect(queued).rejects.toMatchObject({ name: 'AbortError' }); + await vi.waitFor(() => expect(database.put).toHaveBeenCalledOnce()); + revision.set(2); + release?.(); + await rejected; + expect(entries.size).toBe(0); + expect(repository.pendingCount()).toBe(0); + expect(repository.hasUnsyncedChanges()).toBe(false); + }); + + it('retries a failed consumption with its original physical fact and operation identity', async () => { + const entries = new Map(); + const database = inMemoryDatabase(entries, '/api/organizations/org-1'); + const repository = build(database); + await repository.queueInventoryConsumption('org-1', consumption, 'account', () => true); + const operationId = `inventory:${consumption.clientOperationId}`; + await repository.markOutboxFailed(operationId, 'Reference needs review.'); + expect(repository.pendingCount()).toBe(0); + const initial = entries.get(operationId); + + await repository.queueInventoryConsumption('org-1', { ...consumption }, 'account', () => true); + + expect(entries.size).toBe(1); + expect(entries.get(operationId)).toMatchObject({ + id: initial?.id, + createdAt: initial?.createdAt, + payload: initial?.payload, + status: 'pending', + error: null, + }); + expect(repository.pendingCount()).toBe(1); + }); + + it.each([ + { quantity: '3.000000' }, + { occurredAt: '2026-10-06T10:01:00.123Z' }, + { interventionId: 'intervention-2' }, + ])('rejects reuse of a consumption identity for changed physical fields %j', async (changed) => { + const entries = new Map(); + const database = inMemoryDatabase(entries, '/api/organizations/org-1'); + const repository = build(database); + await repository.queueInventoryConsumption('org-1', consumption, 'account', () => true); + const original = entries.get(`inventory:${consumption.clientOperationId}`); + database.put.mockClear(); + + await expect( + repository.queueInventoryConsumption( + 'org-1', + { ...consumption, ...changed }, + 'account', + () => true, + ), + ).rejects.toThrow('another physical fact'); + + expect(entries.get(`inventory:${consumption.clientOperationId}`)).toEqual(original); + expect(database.put).not.toHaveBeenCalled(); + expect(repository.pendingCount()).toBe(1); + }); + + it('does not automatically retry a declaration awaiting conflict review', async () => { + const entries = new Map(); + const repository = build(inMemoryDatabase(entries, '/api/organizations/org-1')); + await repository.queueInventoryConsumption('org-1', consumption, 'account', () => true); + const id = `inventory:${consumption.clientOperationId}`; + await repository.markOutboxConflict(id, 'Review the original declaration.'); + await expect( + repository.queueInventoryConsumption('org-1', consumption, 'account', () => true), + ).rejects.toThrow('conflicted declaration'); + expect(entries.get(id)?.status).toBe('conflict'); + expect(repository.pendingCount()).toBe(0); + }); + + it('refuses a declaration for another actor or revoked caller scope before writing', async () => { + const entries = new Map(); + const database = inMemoryDatabase(entries, '/api/organizations/org-1'); + const repository = build(database); + await expect( + repository.queueInventoryConsumption('org-1', consumption, 'other-account', () => true), + ).rejects.toMatchObject({ name: 'AbortError' }); + await expect( + repository.queueInventoryConsumption('org-1', consumption, 'account', () => false), + ).rejects.toMatchObject({ name: 'AbortError' }); + expect(database.put).not.toHaveBeenCalled(); + expect(repository.pendingCount()).toBe(0); + }); + it.each(['replacement', 'same-account return'])( 'does not queue old work when binding resolves after %s', async (change) => { diff --git a/src/app/features/organization/features/interventions/data-access/services/intervention-offline/testing/intervention-time.repository.spec.ts b/src/app/features/organization/features/interventions/data-access/services/intervention-offline/testing/intervention-time.repository.spec.ts index 8e4c24fe4..9f89b256e 100644 --- a/src/app/features/organization/features/interventions/data-access/services/intervention-offline/testing/intervention-time.repository.spec.ts +++ b/src/app/features/organization/features/interventions/data-access/services/intervention-offline/testing/intervention-time.repository.spec.ts @@ -1,5 +1,8 @@ import { TestBed } from '@angular/core/testing'; -import type { InterventionTimeDraft } from '@features/organization/features/interventions/models'; +import type { + InterventionTimeDraft, + InterventionTimeEntry, +} from '@features/organization/features/interventions/models'; import { InterventionDatabaseService } from '../intervention-database.service'; import { InterventionTimeRepository } from '../intervention-time.repository'; import type { @@ -35,6 +38,29 @@ describe('InterventionTimeRepository', () => { workItemId: 'task-1', draft, }; + const scope = { interventionId: 'intervention-1', workItemId: 'task-1', actorId: 'member-1' }; + const row: InterventionTimeEntry = { + id: 'entry-1', + workItemId: 'task-1', + memberId: 'member-1', + workedOn: '2026-09-21', + minutes: 60, + revision: 1, + cancelled: false, + createdBy: 'member-1', + updatedBy: 'member-1', + createdAt: '2026-09-21T10:00:00Z', + updatedAt: '2026-09-21T10:00:00Z', + versions: [], + totalVersions: 1, + nextBeforeRevision: null, + }; + const pageRecord: InterventionTimeRecord = { + ...journal, + audience: 'member:member-1', + entries: [row], + pagination: { page: 2, itemsPerPage: 30, totalItems: 75, nextPage: 3 }, + }; beforeEach(() => { vi.resetAllMocks(); @@ -51,6 +77,135 @@ describe('InterventionTimeRepository', () => { }); repository = TestBed.inject(InterventionTimeRepository); }); + it('stores page metadata under a separate audience-bound key without overwriting the complete journal', async () => { + await repository.saveJournal(pageRecord, 'account-1'); + expect(database.put).toHaveBeenCalledExactlyOnceWith( + 'timeJournals', + 'page:v2:task-1:member:member-1:30:2', + pageRecord, + expect.any(Function), + ); + const current = database.put.mock.calls[0][3] as () => boolean; + expect(current()).toBe(true); + owner = 'other-account'; + expect(current()).toBe(false); + }); + it('returns only the requested cached page with exact count and continuation', async () => { + database.get.mockResolvedValueOnce(pageRecord); + await expect(repository.readJournalPage(scope, 2)).resolves.toEqual({ + entries: [row], + page: 2, + itemsPerPage: 30, + totalItems: 75, + nextPage: 3, + }); + expect(database.get).toHaveBeenCalledExactlyOnceWith( + 'timeJournals', + 'page:v2:task-1:member:member-1:30:2', + ); + }); + it('does not expose a formerly broad manager page under beneficiary-only authority', async () => { + database.get + .mockResolvedValueOnce({ ...pageRecord, audience: 'all' }) + .mockResolvedValueOnce(null); + await expect(repository.readJournalPage(scope, 2)).resolves.toBeNull(); + expect(database.get.mock.calls[0]).toEqual([ + 'timeJournals', + 'page:v2:task-1:member:member-1:30:2', + ]); + }); + it('ignores polluted older page labels without changing stored histories or pending work', async () => { + const oldPage = { ...pageRecord, entries: [row, { ...row, id: 'foreign', memberId: 'other' }] }; + const records = new Map([['page:task-1:member:member-1:30:2', oldPage]]); + database.get.mockImplementation( + async (_store: string, key: string) => records.get(key) ?? null, + ); + await expect(repository.readJournalPage(scope, 2)).resolves.toBeNull(); + expect(database.get.mock.calls).toEqual([ + ['timeJournals', 'page:v2:task-1:member:member-1:30:2'], + ['timeJournals', 'task-1'], + ]); + expect(records.get('page:task-1:member:member-1:30:2')).toBe(oldPage); + expect(oldPage.entries).toHaveLength(2); + expect(database.put).not.toHaveBeenCalled(); + expect(database.remove).not.toHaveBeenCalled(); + }); + it('rejects a whole restricted page containing foreign entries rather than filtering its totals', async () => { + const polluted = { + ...pageRecord, + entries: [row, { ...row, id: 'foreign', memberId: 'other' }], + }; + await expect(repository.saveJournal(polluted, 'account-1')).rejects.toThrow( + 'authorized audience', + ); + expect(database.put).not.toHaveBeenCalled(); + database.get.mockResolvedValueOnce(polluted); + await expect(repository.readJournalPage(scope, 2)).resolves.toBeNull(); + expect(database.get).toHaveBeenCalledOnce(); + expect(polluted.entries).toHaveLength(2); + expect(polluted.pagination?.totalItems).toBe(75); + }); + it('filters complete legacy history before repaging and keeps stored revisions intact', async () => { + const own = Array.from({ length: 31 }, (_, index) => ({ ...row, id: `own-${index}` })); + const foreign = Array.from({ length: 35 }, (_, index) => ({ + ...row, + id: `foreign-${index}`, + memberId: 'other', + })); + const complete = { ...journal, entries: [...foreign, ...own] }; + database.get.mockResolvedValueOnce(null).mockResolvedValueOnce(complete); + const second = await repository.readJournalPage(scope, 2); + expect(second).toMatchObject({ page: 2, totalItems: 31, nextPage: null }); + expect(second?.entries).toHaveLength(1); + expect(second?.entries.every((entry) => entry.memberId === scope.actorId)).toBe(true); + expect(complete.entries).toHaveLength(66); + expect(database.put).not.toHaveBeenCalled(); + expect(database.remove).not.toHaveBeenCalled(); + }); + it('filters and slices compatible legacy snapshots while refusing unproven broad completeness', async () => { + const legacy = { ...journal, entries: [row, { ...row, id: 'entry-other', memberId: 'other' }] }; + database.get.mockResolvedValueOnce(null).mockResolvedValueOnce(legacy); + await expect(repository.readJournalPage(scope, 1)).resolves.toEqual({ + entries: [row], + page: 1, + itemsPerPage: 30, + totalItems: 1, + nextPage: null, + }); + database.get.mockResolvedValueOnce(null); + await expect( + repository.readJournalPage({ ...scope, manageOthers: true }, 1), + ).resolves.toBeNull(); + }); + it('keeps missing offline pages unknown and excludes late page reads from another account', async () => { + database.get.mockResolvedValue(null); + await expect(repository.readJournalPage(scope, 2)).resolves.toBeNull(); + database.get.mockImplementationOnce(async () => { + owner = 'account-2'; + return pageRecord; + }); + await expect(repository.readJournalPage(scope, 2)).resolves.toBeNull(); + }); + it('bounds legacy embedded revision history without mutating the durable complete snapshot', async () => { + const versions = [1, 2, 3].map((revision) => ({ + revision, + workedOn: row.workedOn, + minutes: row.minutes, + note: null, + cancelled: false, + actorId: row.memberId, + recordedAt: row.updatedAt, + })); + const historical = { ...row, revision: 3, totalVersions: 3, nextBeforeRevision: 3, versions }; + database.get + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ ...journal, entries: [historical] }); + const page = await repository.readJournalPage(scope); + expect(page?.entries[0].versions).toEqual([versions[2]]); + expect(page?.entries[0].totalVersions).toBe(3); + expect(page?.entries[0].nextBeforeRevision).toBe(3); + expect(historical.versions).toHaveLength(3); + }); it('distinguishes a cached empty journal from unavailable history', async () => { database.get.mockResolvedValueOnce(journal).mockResolvedValueOnce(null); @@ -101,7 +256,7 @@ describe('InterventionTimeRepository', () => { await repository.saveDraft(draftRecord, 'account-1'); await repository.clearDraft('task-1', 'account-1'); expect(database.put.mock.calls).toEqual([ - ['timeJournals', 'task-1', journal], + ['timeJournals', 'task-1', journal, expect.any(Function)], ['timeDrafts', 'task-1', draftRecord], ]); expect(database.remove).toHaveBeenCalledExactlyOnceWith('timeDrafts', 'task-1'); diff --git a/src/app/features/organization/features/interventions/data-access/services/intervention-offline/testing/intervention-workspace.repository.spec.ts b/src/app/features/organization/features/interventions/data-access/services/intervention-offline/testing/intervention-workspace.repository.spec.ts index 3fca644ca..f50dd4363 100644 --- a/src/app/features/organization/features/interventions/data-access/services/intervention-offline/testing/intervention-workspace.repository.spec.ts +++ b/src/app/features/organization/features/interventions/data-access/services/intervention-offline/testing/intervention-workspace.repository.spec.ts @@ -1,4 +1,5 @@ import { TestBed } from '@angular/core/testing'; +import type { EquipmentTypeOutput } from '@features/organization/features/equipments/models'; import type { InterventionChangeOutput, InterventionIssueOutput, @@ -15,6 +16,7 @@ import { InterventionWorkspaceRepository } from '../intervention-workspace.repos */ function inMemoryDatabase(stores: Map>): { ensureOwnerBound: ReturnType; + currentOwnerId: ReturnType; put: ReturnType; putMany: ReturnType; get: ReturnType; @@ -32,9 +34,13 @@ function inMemoryDatabase(stores: Map>): { return { ensureOwnerBound: vi.fn().mockResolvedValue(undefined), - put: vi.fn(async (storeName: string, key: string, value: unknown) => { - storeFor(storeName).set(key, value); - }), + currentOwnerId: vi.fn().mockReturnValue('account-1'), + put: vi.fn( + async (storeName: string, key: string, value: unknown, isCurrent?: () => boolean) => { + if (isCurrent && !isCurrent()) throw new DOMException('Owner changed.', 'AbortError'); + storeFor(storeName).set(key, value); + }, + ), putMany: vi.fn( async (storeName: string, entries: ReadonlyArray<{ key: string; value: unknown }>) => { const store = storeFor(storeName); @@ -82,6 +88,27 @@ const change = { const issue = { id: 'issue-1' } as unknown as InterventionIssueOutput; +const equipmentTypes: readonly EquipmentTypeOutput[] = [ + { + '@id': '/api/organizations/org-1/equipment-types/fire_extinguisher', + '@type': 'EquipmentType', + value: 'fire_extinguisher', + label: 'Extinguisher', + family: 'fire', + archived: false, + revision: 1, + }, + { + '@id': '/api/organizations/org-1/equipment-types/legacy_pump', + '@type': 'EquipmentType', + value: 'legacy_pump', + label: 'Legacy fire pump', + family: 'fire', + archived: true, + revision: 4, + }, +]; + describe('InterventionWorkspaceRepository', () => { describe('saveWorkspace / getWorkspace', () => { it('should round-trip a saved workspace through the normalized stores', async () => { @@ -139,6 +166,185 @@ describe('InterventionWorkspaceRepository', () => { }); }); + describe('equipment catalogue persistence', () => { + const catalogKey = 'equipmentCatalog:account-1:org-1:intervention-1'; + + it('restores the complete catalogue with archived entries and refreshed labels', async () => { + const stores = new Map>(); + const { repository } = build(stores); + await repository.saveWorkspace(intervention, [workItem], [change], [issue]); + await repository.saveEquipmentCatalog('intervention-1', 'org-1', equipmentTypes, 'account-1'); + expect((await repository.getWorkspace('intervention-1'))?.equipmentCatalog).toEqual({ + version: 1, + accountId: 'account-1', + organizationId: 'org-1', + capturedAt: expect.any(String), + entries: equipmentTypes, + }); + + const updated = equipmentTypes.map((entry) => ({ + ...entry, + label: `${entry.label} updated`, + revision: entry.revision + 1, + })); + await repository.saveEquipmentCatalog('intervention-1', 'org-1', updated, 'account-1'); + expect((await repository.getWorkspace('intervention-1'))?.equipmentCatalog?.entries).toEqual( + updated, + ); + expect(stores.get('metadata')?.get(catalogKey)).toMatchObject({ entries: updated }); + expect(updated[1]?.archived).toBe(true); + }); + + it('preserves catalogue metadata through replacement and local workspace merges', async () => { + const stores = new Map>(); + const { repository } = build(stores); + await repository.saveWorkspace(intervention, [workItem], [], []); + await repository.saveEquipmentCatalog('intervention-1', 'org-1', equipmentTypes, 'account-1'); + await repository.saveWorkspace(intervention, [workItem], [change], [issue]); + await repository.saveWorkspace( + intervention, + [{ ...workItem, status: 'completed' }], + [], + [], + [], + { + replace: false, + }, + ); + + const snapshot = await repository.getWorkspace('intervention-1'); + expect(snapshot?.equipmentCatalog?.entries).toEqual(equipmentTypes); + expect(snapshot?.workItems[0]?.status).toBe('completed'); + }); + + it('keeps historical workspaces readable when no catalogue was saved', async () => { + const { repository } = build(new Map()); + await repository.saveWorkspace(intervention, [workItem], [change], [issue]); + const snapshot = await repository.getWorkspace('intervention-1'); + expect(snapshot?.workItems).toEqual([workItem]); + expect(snapshot).not.toHaveProperty('equipmentCatalog'); + }); + + it.each(['accountId', 'organizationId', 'version'] as const)( + 'rejects mismatched cached catalogue %s metadata', + async (field) => { + const stores = new Map>(); + const { repository } = build(stores); + await repository.saveWorkspace(intervention, [], [], []); + const invalid = { + version: 1, + accountId: 'account-1', + organizationId: 'org-1', + capturedAt: '2026-10-06T09:00:00Z', + entries: equipmentTypes, + [field]: field === 'version' ? 2 : 'other-owner', + }; + stores.get('metadata')?.set(catalogKey, invalid); + expect(await repository.getWorkspace('intervention-1')).not.toHaveProperty( + 'equipmentCatalog', + ); + }, + ); + + it('does not cache a catalogue for another organization or a missing workspace', async () => { + const stores = new Map>(); + const { repository, database } = build(stores); + await repository.saveWorkspace(intervention, [], [], []); + database.put.mockClear(); + await repository.saveEquipmentCatalog('intervention-1', 'org-2', equipmentTypes, 'account-1'); + await repository.saveEquipmentCatalog('missing', 'org-1', equipmentTypes, 'account-1'); + expect(database.put).not.toHaveBeenCalled(); + }); + + it.each([null, 'account-2'])( + 'ignores a stale requesting owner %s before binding', + async (owner) => { + const { repository, database } = build(new Map()); + await repository.saveEquipmentCatalog('intervention-1', 'org-1', equipmentTypes, owner); + expect(database.ensureOwnerBound).not.toHaveBeenCalled(); + expect(database.get).not.toHaveBeenCalled(); + expect(database.put).not.toHaveBeenCalled(); + }, + ); + + it('abandons a catalogue after the account changes while binding the database', async () => { + const { repository, database } = build(new Map()); + let finishBinding!: () => void; + database.ensureOwnerBound.mockReturnValueOnce( + new Promise((resolve) => { + finishBinding = resolve; + }), + ); + const saving = repository.saveEquipmentCatalog( + 'intervention-1', + 'org-1', + equipmentTypes, + 'account-1', + ); + database.currentOwnerId.mockReturnValue('account-2'); + finishBinding(); + await saving; + expect(database.get).not.toHaveBeenCalled(); + expect(database.put).not.toHaveBeenCalled(); + }); + + it('guards the metadata transaction against an account switch while IndexedDB opens', async () => { + const stores = new Map>(); + const { repository, database } = build(stores); + await repository.saveWorkspace(intervention, [], [], []); + database.put.mockImplementationOnce( + async (_store: string, _key: string, _value: unknown, isCurrent?: () => boolean) => { + database.currentOwnerId.mockReturnValue('account-2'); + if (!isCurrent?.()) throw new DOMException('Owner changed.', 'AbortError'); + }, + ); + await expect( + repository.saveEquipmentCatalog('intervention-1', 'org-1', equipmentTypes, 'account-1'), + ).rejects.toMatchObject({ name: 'AbortError' }); + expect(stores.get('metadata')?.has(catalogKey)).toBe(false); + }); + + it('does not write a catalogue after its owning workspace read crosses accounts', async () => { + const { repository, database } = build(new Map()); + database.get.mockImplementationOnce(async () => { + database.currentOwnerId.mockReturnValue('account-2'); + return intervention; + }); + await repository.saveEquipmentCatalog('intervention-1', 'org-1', equipmentTypes, 'account-1'); + expect(database.put).not.toHaveBeenCalled(); + }); + + it('discards a late workspace read after its catalogue is read under another account', async () => { + const stores = new Map>(); + const { repository, database } = build(stores); + await repository.saveWorkspace(intervention, [workItem], [], []); + await repository.saveEquipmentCatalog('intervention-1', 'org-1', equipmentTypes, 'account-1'); + database.get.mockImplementation(async (storeName: string, key: string) => { + const value = stores.get(storeName)?.get(key) ?? null; + if (storeName === 'metadata') database.currentOwnerId.mockReturnValue('account-2'); + return value; + }); + expect(await repository.getWorkspace('intervention-1')).toBeNull(); + }); + + it('reads only the catalogue key belonging to the current account', async () => { + const stores = new Map>(); + const { repository, database } = build(stores); + await repository.saveWorkspace(intervention, [], [], []); + await repository.saveEquipmentCatalog('intervention-1', 'org-1', equipmentTypes, 'account-1'); + database.currentOwnerId.mockReturnValue('account-2'); + database.get.mockClear(); + expect(await repository.getWorkspace('intervention-1')).not.toHaveProperty( + 'equipmentCatalog', + ); + expect(database.get).toHaveBeenCalledWith( + 'metadata', + 'equipmentCatalog:account-2:org-1:intervention-1', + ); + expect(database.get).not.toHaveBeenCalledWith('metadata', catalogKey); + }); + }); + describe('listInterventions', () => { it('should scope the local interventions to the requested organization', async () => { const stores = new Map>(); diff --git a/src/app/features/organization/features/interventions/data-access/services/intervention-time/intervention-time.service.ts b/src/app/features/organization/features/interventions/data-access/services/intervention-time/intervention-time.service.ts index 4129ccd2e..fc45d23e3 100644 --- a/src/app/features/organization/features/interventions/data-access/services/intervention-time/intervention-time.service.ts +++ b/src/app/features/organization/features/interventions/data-access/services/intervention-time/intervention-time.service.ts @@ -4,6 +4,7 @@ import { HydraApiService } from '@core/api'; import type { InterventionTimeJournalOutput, InterventionTimeEntryOutput, + InterventionTimeEntryVersionsOutput, WriteInterventionTimeEntryInput, } from '@features/organization/features/interventions/models'; @@ -29,11 +30,71 @@ export class InterventionTimeService extends HydraApiService { * @since 1.0.0 * * @param {string} taskId - Task identifier. - * @returns {Observable} Entries including correction history. + * @param {number} page - Requested positive journal page. + * @param {number} itemsPerPage - Bounded server page size. + * @param {boolean} ownOnly - Restricts the journal to the caller even if management is granted. + * + * @returns {Observable} One authorized page with current versions. */ - public journal(taskId: string): Observable { + public journal( + taskId: string, + page = 1, + itemsPerPage = 30, + ownOnly = false, + ): Observable { return this.getOne( `/api/intervention-work-items/${taskId}/time-entries`, + { params: { page, itemsPerPage, ownOnly } }, + ); + } + + /** + * Method getEntry + * @method getEntry + * + * @description + * Reads the current entry directly for explicit conflict review without scanning journal pages. + * + * @access public + * @since unreleased + * + * @param {string} taskId - Owning task. + * @param {string} entryId - Authorized entry to review. + * + * @returns {Observable} Bounded current entry projection. + */ + public getEntry(taskId: string, entryId: string): Observable { + return this.getOne( + `/api/intervention-work-items/${taskId}/time-entries/${entryId}`, + ); + } + + /** + * Method versions + * @method versions + * + * @description + * Reads one immutable history page only when explicitly requested. + * + * @access public + * @since unreleased + * + * @param {string} taskId - Owning task. + * @param {string} entryId - Authorized journal entry. + * @param {number} beforeRevision - Exclusive cursor; omitted for newest revisions. + * @param {number} itemsPerPage - Bounded history page size. + * + * @returns {Observable} Authorized revision page. + */ + public versions( + taskId: string, + entryId: string, + beforeRevision?: number, + itemsPerPage = 30, + ): Observable { + return this.getOne( + `/api/intervention-work-items/${taskId}/time-entries/${entryId}/versions`, + { params: { itemsPerPage, ...(beforeRevision === undefined ? {} : { beforeRevision }) } }, ); } @@ -49,6 +110,7 @@ export class InterventionTimeService extends HydraApiService { * * @param {string} taskId - Task identifier. * @param {WriteInterventionTimeEntryInput} input - Actual work. + * * @returns {Observable} Current entry. */ public createEntry( @@ -74,6 +136,7 @@ export class InterventionTimeService extends HydraApiService { * @param {string} taskId - Task identifier. * @param {WriteInterventionTimeEntryInput} input - Corrected complete entry. * @param {number} revision - Previously read journal revision. + * * @returns {Observable} Corrected entry. */ public correctEntry( @@ -101,6 +164,7 @@ export class InterventionTimeService extends HydraApiService { * @param {string} taskId - Task identifier. * @param {string} entryId - Entry identifier. * @param {number} revision - Previously read journal revision. + * * @returns {Observable} Completion. */ public cancelEntry(taskId: string, entryId: string, revision: number): Observable { diff --git a/src/app/features/organization/features/interventions/data-access/services/intervention-time/testing/intervention-time.service.spec.ts b/src/app/features/organization/features/interventions/data-access/services/intervention-time/testing/intervention-time.service.spec.ts index a9127c4fc..4e0d60e6f 100644 --- a/src/app/features/organization/features/interventions/data-access/services/intervention-time/testing/intervention-time.service.spec.ts +++ b/src/app/features/organization/features/interventions/data-access/services/intervention-time/testing/intervention-time.service.spec.ts @@ -27,6 +27,66 @@ describe('InterventionTimeService', () => { http = TestBed.inject(HttpTestingController); }); afterEach(() => http.verify()); + it('reads only the requested journal page and preserves exact server continuation metadata', () => { + const received = vi.fn(); + service.journal('task', 2, 10).subscribe(received); + const request = http.expectOne((candidate) => candidate.url === url); + expect(request.request.method).toBe('GET'); + expect(request.request.params.get('page')).toBe('2'); + expect(request.request.params.get('itemsPerPage')).toBe('10'); + expect(request.request.params.get('ownOnly')).toBe('false'); + const page = { + workItemId: 'task', + entries: [], + page: 2, + itemsPerPage: 10, + totalItems: 25, + nextPage: 3, + }; + request.flush(page); + expect(received).toHaveBeenCalledWith(page); + http.expectNone((candidate) => candidate.url.includes('/versions')); + }); + it('requests beneficiary-only pagination explicitly even when the server may grant management', () => { + service.journal('task', 2, 10, true).subscribe(); + const request = http.expectOne((candidate) => candidate.url === url); + expect(request.request.params.get('ownOnly')).toBe('true'); + expect(request.request.params.get('page')).toBe('2'); + expect(request.request.params.get('itemsPerPage')).toBe('10'); + request.flush({ entries: [], page: 2, itemsPerPage: 10, totalItems: 10, nextPage: null }); + }); + it('requests revision pages with an exclusive cursor and never sends an absent cursor', () => { + service.versions('task', 'entry').subscribe(); + const first = http.expectOne((candidate) => candidate.url === url + '/entry/versions'); + expect(first.request.params.get('itemsPerPage')).toBe('30'); + expect(first.request.params.has('beforeRevision')).toBe(false); + first.flush({ + id: 'entry', + versions: [], + totalItems: 60, + itemsPerPage: 30, + nextBeforeRevision: 31, + }); + service.versions('task', 'entry', 31, 10).subscribe(); + const older = http.expectOne((candidate) => candidate.url === url + '/entry/versions'); + expect(older.request.params.get('beforeRevision')).toBe('31'); + expect(older.request.params.get('itemsPerPage')).toBe('10'); + older.flush({ + id: 'entry', + versions: [], + totalItems: 60, + itemsPerPage: 10, + nextBeforeRevision: 21, + }); + }); + it('reads the current conflicted entry directly and propagates hidden beneficiary errors', () => { + const failed = vi.fn(); + service.getEntry('task', 'entry').subscribe({ error: failed }); + const request = http.expectOne(url + '/entry'); + expect(request.request.method).toBe('GET'); + request.flush({ status: 404 }, { status: 404, statusText: 'Not Found' }); + expect(failed).toHaveBeenCalledWith(expect.objectContaining({ status: 404 })); + }); it('creates with the exact stable ID and without an operational revision', () => { service.createEntry('task', input).subscribe(); const request = http.expectOne(url); diff --git a/src/app/features/organization/features/interventions/data-access/services/intervention/testing/intervention.service.spec.ts b/src/app/features/organization/features/interventions/data-access/services/intervention/testing/intervention.service.spec.ts index a3c031b5d..bf6f60fe4 100644 --- a/src/app/features/organization/features/interventions/data-access/services/intervention/testing/intervention.service.spec.ts +++ b/src/app/features/organization/features/interventions/data-access/services/intervention/testing/intervention.service.spec.ts @@ -8,6 +8,7 @@ import type { InterventionStatisticsOutput, InterventionWorkItemOutput, PublicationOutput, + InterventionWorkItemExecutionResultInput, } from '@features/organization/features/interventions/models'; import { InterventionService } from '../intervention.service'; @@ -96,6 +97,90 @@ describe('InterventionService', () => { request.flush({}); }); + it('patches an equipment execution fact with its captured revision and execution instant', () => { + const executionResult: InterventionWorkItemExecutionResultInput = { + equipmentId: 'd1d145d0-e1c5-4e9c-a36e-1b2f267068ce', + performedAt: '2026-10-05T16:35:00+02:00', + outcome: 'performed', + workPerformed: 'Cleaned and adjusted the valve.', + }; + const output = { + id: 'work-item-1', + status: 'completed', + operationId: 'maintenance-operation', + occurrenceId: 'maintenance-occurrence', + operationKind: 'maintenance', + executionResult: { + ...executionResult, + authorId: 'recording-member', + operationId: 'maintenance-operation', + occurrenceId: 'maintenance-occurrence', + state: 'staged', + validatedAt: null, + }, + } as InterventionWorkItemOutput; + service + .updateWorkItem('work-item-1', { status: 'completed', executionResult }, 7) + .subscribe((result) => expect(result).toEqual(output)); + + const request = httpMock.expectOne(`${mockEnv.apiUrl}/api/intervention-work-items/work-item-1`); + expect(request.request.method).toBe('PATCH'); + expect(request.request.headers.get('If-Match')).toBe('"revision-7"'); + expect(request.request.body).toEqual({ status: 'completed', executionResult }); + request.flush(output); + }); + + it('forwards a null result declaration for server validation', () => { + service.updateWorkItem('work-item-1', { executionResult: null }, 8).subscribe(); + const request = httpMock.expectOne(`${mockEnv.apiUrl}/api/intervention-work-items/work-item-1`); + expect(request.request.body).toEqual({ executionResult: null }); + expect(request.request.headers.get('If-Match')).toBe('"revision-8"'); + request.flush({}); + }); + + it('forwards preventive identifiers during equipment work-item creation', () => { + const input = { + intervention: '/api/interventions/intervention-1', + action: 'maintenance' as const, + target: '/api/equipment/equipment-1', + source: 'planned' as const, + required: true, + operationId: 'maintenance-operation', + occurrenceId: 'maintenance-occurrence', + operationKind: 'maintenance' as const, + }; + service.createWorkItem(input).subscribe(); + const request = httpMock.expectOne(`${mockEnv.apiUrl}/api/intervention-work-items`); + expect(request.request.method).toBe('POST'); + expect(request.request.body).toEqual(input); + request.flush({}); + }); + + it('propagates an execution revision conflict for explicit resolution', () => { + const problem = { type: '/problems/revision-conflict', detail: 'Another result was recorded.' }; + const failure = vi.fn(); + service + .updateWorkItem( + 'work-item-1', + { + status: 'in_progress', + executionResult: { + equipmentId: 'equipment-1', + performedAt: '2026-10-05T16:35:00+02:00', + outcome: 'failed', + workPerformed: 'Repair attempt failed.', + }, + }, + 7, + ) + .subscribe({ error: failure }); + const request = httpMock.expectOne(`${mockEnv.apiUrl}/api/intervention-work-items/work-item-1`); + request.flush(problem, { status: 412, statusText: 'Precondition Failed' }); + expect(failure).toHaveBeenCalledWith( + expect.objectContaining({ status: 412, detail: problem.detail }), + ); + }); + it('posts a team assignment with the current revision as If-Match', () => { service.assignTeam('intervention-1', { teamId: 'team-1' }, 7).subscribe(); diff --git a/src/app/features/organization/features/interventions/interventions.routes.ts b/src/app/features/organization/features/interventions/interventions.routes.ts index 007f033f0..3d3d51620 100644 --- a/src/app/features/organization/features/interventions/interventions.routes.ts +++ b/src/app/features/organization/features/interventions/interventions.routes.ts @@ -4,6 +4,7 @@ import { Router } from '@angular/router'; import { DASHBOARD_MOBILE_NAVIGATION_ROOT_DATA_KEY, type DashboardRouteData } from '@core/routing'; import { organizationPermissionGuard } from '@features/organization/http/guards'; import { ORGANIZATION_PERMISSION } from '@features/organization/models'; +import { unsavedChangesGuard } from '@shared/unsaved-changes'; import { interventionTitleResolver } from './http/resolvers'; import { InterventionStore } from './state'; import { InterventionPlanningOptionsStore } from './state/intervention-planning-options'; @@ -39,7 +40,6 @@ function redirectToInterventionView(view: 'board' | 'calendar' | 'recurrences'): /** * Constant INTERVENTION_ROUTES - * @const INTERVENTION_ROUTES * * @description * Organization-scoped intervention workflows: the index at @@ -102,6 +102,8 @@ function redirectToInterventionView(view: 'board' | 'calendar' | 'recurrences'): * @since 2.1.0 * * @type {Routes} + * + * @const INTERVENTION_ROUTES */ export const INTERVENTION_ROUTES: Routes = [ { @@ -141,6 +143,7 @@ export const INTERVENTION_ROUTES: Routes = [ }, { path: ':interventionId', + canDeactivate: [unsavedChangesGuard], loadComponent: () => import('./ui/pages/intervention-detail-page/intervention-detail-page.component').then( (m) => m.InterventionDetailPage, diff --git a/src/app/features/organization/features/interventions/models/index.ts b/src/app/features/organization/features/interventions/models/index.ts index 584933f54..e6a067b91 100644 --- a/src/app/features/organization/features/interventions/models/index.ts +++ b/src/app/features/organization/features/interventions/models/index.ts @@ -1,4 +1,5 @@ /** + * @description * Interventions model public exports. */ export type { InterventionExportOptions } from './intervention/intervention-export-options.interface'; @@ -28,6 +29,10 @@ export type { InterventionQueueKey } from './intervention-queue/intervention-que export type { InterventionUnsyncedEntry } from './intervention-queue/intervention-unsynced-entry.interface'; export type { InterventionAllowedActionsOutput } from './intervention/intervention-allowed-actions-output.interface'; export type { InterventionOutput } from './intervention/intervention-output.interface'; +export type { InterventionClosureSnapshotOutput } from './intervention-closure/intervention-closure-snapshot-output.interface'; +export type { InterventionEquipmentCatalogSnapshot } from './intervention-equipment-catalog/intervention-equipment-catalog-snapshot.interface'; +export type { InterventionReplacementContext } from './intervention-replacement-context/intervention-replacement-context.interface'; +export type { InterventionReplacementContextRequest } from './intervention-replacement-context/intervention-replacement-context-request.interface'; export type { InterventionDuplicatePrefill } from './intervention-duplicate/intervention-duplicate-prefill.interface'; export type { InterventionPriority } from './intervention/intervention-priority.type'; export type { InterventionStatus } from './intervention/intervention-status.type'; @@ -54,8 +59,14 @@ export type { InterventionActivityOutput } from './intervention-activity/interve export type { InterventionStatusChangePayload } from './intervention-activity/intervention-status-change-payload.interface'; export type { CreateInterventionWorkItemInput } from './intervention-work-item/create-intervention-work-item-input.interface'; export type { InterventionWorkItemAction } from './intervention-work-item/intervention-work-item-action.type'; +export type { InterventionEquipmentContext } from './intervention-equipment-context/intervention-equipment-context.interface'; export type { InterventionWorkItemAssignee } from './intervention-work-item/intervention-work-item-assignee.interface'; export type { InterventionWorkItemOutput } from './intervention-work-item/intervention-work-item-output.interface'; +export type { InterventionWorkItemExecutionOutcome } from './intervention-work-item/intervention-work-item-execution-outcome.type'; +export type { InterventionWorkItemExecutionResultInput } from './intervention-work-item/intervention-work-item-execution-result-input.interface'; +export type { InterventionWorkItemExecutionResultOutput } from './intervention-work-item/intervention-work-item-execution-result-output.interface'; +export type { InterventionWorkItemExecutionResultState } from './intervention-work-item/intervention-work-item-execution-result-state.type'; +export type { InterventionWorkItemOperationKind } from './intervention-work-item/intervention-work-item-operation-kind.type'; export type { InterventionWorkItemSource } from './intervention-work-item/intervention-work-item-source.type'; export type { InterventionWorkItemTarget } from './intervention-work-item/intervention-work-item-target.interface'; export type { InterventionWorkItemStatus } from './intervention-work-item/intervention-work-item-status.type'; @@ -113,6 +124,7 @@ export type { InterventionMentionSegment } from './intervention-mention/interven export type { InterventionMentionQuery } from './intervention-mention/intervention-mention-query.interface'; /** + * @description * The enum presentation registry (`ARCHITECTURE.md` §10.10, exception 1): the * descriptor maps and their resolver are runtime code, and they stay in * `models/` because the descriptor type is meaningless without them. @@ -137,9 +149,13 @@ export type { InterventionTimeEntryVersion } from './intervention-time/intervent export type { InterventionTimeEntry } from './intervention-time/intervention-time-entry.interface'; export type { WriteInterventionTimeEntryInput } from './intervention-time/write-intervention-time-entry-input.interface'; export type { InterventionTimeJournalOutput } from './intervention-time/intervention-time-journal-output.interface'; +export type { InterventionTimeJournalPage } from './intervention-time/intervention-time-journal-page.type'; +export type { InterventionTimeEntryVersionsOutput } from './intervention-time/intervention-time-entry-versions-output.interface'; export type { InterventionTimeEntryOutput } from './intervention-time/intervention-time-entry-output.interface'; export type { InterventionTimeDraft } from './intervention-time/intervention-time-draft.interface'; export type { InterventionTimeScope } from './intervention-time/intervention-time-scope.interface'; export type { InterventionTimeWrite } from './intervention-time/intervention-time-write.type'; export type { InterventionTimeEntryView } from './intervention-time/intervention-time-entry-view.interface'; export type { InterventionTimeJournalView } from './intervention-time/intervention-time-journal-view.interface'; +export type { InterventionInventorySnapshot } from './intervention-inventory/intervention-inventory-snapshot.interface'; +export type { InterventionInventoryScope } from './intervention-inventory/intervention-inventory-scope.interface'; diff --git a/src/app/features/organization/features/interventions/models/intervention-closure/intervention-closure-snapshot-output.interface.ts b/src/app/features/organization/features/interventions/models/intervention-closure/intervention-closure-snapshot-output.interface.ts new file mode 100644 index 000000000..0041d854a --- /dev/null +++ b/src/app/features/organization/features/interventions/models/intervention-closure/intervention-closure-snapshot-output.interface.ts @@ -0,0 +1,195 @@ +/** + * Interface InterventionClosureSnapshotOutput + * @interface InterventionClosureSnapshotOutput + * + * @description + * Immutable publication dossier captured by the server. Historical publications may omit it. + */ +export interface InterventionClosureSnapshotOutput { + /** + * Property version + * @readonly + * + * @description + * Schema version of the immutable closure dossier. + * + * @access public + * + * @type {1 | 2} + */ + readonly version: 1 | 2; + + /** + * Property capturedAt + * @readonly + * + * @description + * Server instant at which the publication dossier was frozen. + * + * @access public + * + * @type {string} + */ + readonly capturedAt: string; + + /** + * Property interventionId + * @readonly + * + * @description + * Intervention identifier represented by the frozen dossier. + * + * @access public + * + * @type {string} + */ + readonly interventionId: string; + + /** + * Property revision + * @readonly + * + * @description + * Captured intervention revision, independent of later operational edits. + * + * @access public + * + * @type {number} + */ + readonly revision: number; + + /** + * Property site + * @readonly + * + * @description + * Site identity at publication, when the intervention has a site. + * + * @access public + * + * @type {{ readonly id: string; readonly name: string } | null} + */ + readonly site: { readonly id: string; readonly name: string } | null; + + /** + * Property customer + * @readonly + * + * @description + * Customer identity at publication, without private contact data. + * + * @access public + * + * @type {{ + * readonly id: string; + * readonly name: string; + * } | null} + */ + readonly customer: { + readonly id: string; + readonly name: string; + } | null; + + /** + * Property memberNames + * @readonly + * + * @description + * Recorded member names used by the frozen report. + * + * @access public + * + * @type {Readonly>} + */ + readonly memberNames: Readonly>; + + /** + * Property workItems + * @readonly + * + * @description + * Raw work-item facts at closure, without live capabilities or resolved target summaries. + * + * @access public + * + * @type {readonly Readonly>[]} + */ + readonly workItems: readonly Readonly>[]; + + /** + * Property attachments + * @readonly + * + * @description + * Evidence metadata captured with the publication dossier. + * + * @access public + * + * @type {readonly { + * readonly id: string; + * readonly fileName: string; + * readonly kind: string; + * readonly mimeType: string; + * readonly size: number; + * readonly label: string | null; + * readonly workItemId: string | null; + * readonly revision: number; + * readonly uploadedAt: string; + * }[]} + */ + readonly attachments: readonly { + readonly id: string; + readonly fileName: string; + readonly kind: string; + readonly mimeType: string; + readonly size: number; + readonly label: string | null; + readonly workItemId: string | null; + readonly revision: number; + readonly uploadedAt: string; + }[]; + + /** + * Property timeEntries + * @readonly + * + * @description + * Time journal facts captured at publication, including recorded cancellations. + * + * @access public + * + * @type {readonly { + * readonly id: string; + * readonly workItemId: string | null; + * readonly memberId: string; + * readonly workedOn: string; + * readonly minutes: number; + * readonly note: string | null; + * readonly cancelled: boolean; + * readonly revision: number; + * }[]} + */ + readonly timeEntries: readonly { + readonly id: string; + readonly workItemId: string | null; + readonly memberId: string; + readonly workedOn: string; + readonly minutes: number; + readonly note: string | null; + readonly cancelled: boolean; + readonly revision: number; + }[]; + + /** + * Property report + * @readonly + * + * @description + * Frozen report context used by the server to reproduce the publication document. + * + * @access public + * + * @type {Readonly>} + */ + readonly report: Readonly>; +} diff --git a/src/app/features/organization/features/interventions/models/intervention-equipment-catalog/intervention-equipment-catalog-snapshot.interface.ts b/src/app/features/organization/features/interventions/models/intervention-equipment-catalog/intervention-equipment-catalog-snapshot.interface.ts new file mode 100644 index 000000000..b4e29e1f0 --- /dev/null +++ b/src/app/features/organization/features/interventions/models/intervention-equipment-catalog/intervention-equipment-catalog-snapshot.interface.ts @@ -0,0 +1,75 @@ +import type { EquipmentTypeOutput } from '@features/organization/features/equipments/models'; + +/** + * Interface InterventionEquipmentCatalogSnapshot + * @interface InterventionEquipmentCatalogSnapshot + * + * @description + * Complete authorized equipment catalogue persisted with an account-scoped field workspace. + */ +export interface InterventionEquipmentCatalogSnapshot { + /** + * Property version + * @readonly + * + * @description + * Snapshot format version used to validate persisted metadata. + * + * @access public + * + * @type {1} + */ + readonly version: 1; + + /** + * Property accountId + * @readonly + * + * @description + * Account that was authorized to read this catalogue. + * + * @access public + * + * @type {string} + */ + readonly accountId: string; + + /** + * Property organizationId + * @readonly + * + * @description + * Organization whose equipment type codes and labels the catalogue describes. + * + * @access public + * + * @type {string} + */ + readonly organizationId: string; + + /** + * Property capturedAt + * @readonly + * + * @description + * Capture instant of the complete authorized catalogue, independent of task execution dates. + * + * @access public + * + * @type {string} + */ + readonly capturedAt: string; + + /** + * Property entries + * @readonly + * + * @description + * Complete server catalogue, including archived types needed to read historical equipment. + * + * @access public + * + * @type {readonly EquipmentTypeOutput[]} + */ + readonly entries: readonly EquipmentTypeOutput[]; +} diff --git a/src/app/features/organization/features/interventions/models/intervention-equipment-context/intervention-equipment-context.interface.ts b/src/app/features/organization/features/interventions/models/intervention-equipment-context/intervention-equipment-context.interface.ts new file mode 100644 index 000000000..1642a9425 --- /dev/null +++ b/src/app/features/organization/features/interventions/models/intervention-equipment-context/intervention-equipment-context.interface.ts @@ -0,0 +1,52 @@ +import type { InterventionWorkItemAction } from '../intervention-work-item/intervention-work-item-action.type'; + +/** + * Interface InterventionEquipmentContext + * @interface InterventionEquipmentContext + * + * @description + * Validated preparation context passed by the equipment dossier, never an API authorization. + */ +export interface InterventionEquipmentContext { + /** + * Property target + * @readonly + * + * @description + * Canonical equipment IRI to prepare in the work-item form. + * + * @access public + * @since unreleased + * + * @type {string} + */ + readonly target: string; + + /** + * Property action + * @readonly + * + * @description + * Equipment operation the planner will confirm. + * + * @access public + * @since unreleased + * + * @type {InterventionWorkItemAction} + */ + readonly action: InterventionWorkItemAction; + + /** + * Property site + * @readonly + * + * @description + * Optional root-site IRI supplied by the equipment dossier. + * + * @access public + * @since unreleased + * + * @type {string} + */ + readonly site: string; +} diff --git a/src/app/features/organization/features/interventions/models/intervention-inventory/intervention-inventory-scope.interface.ts b/src/app/features/organization/features/interventions/models/intervention-inventory/intervention-inventory-scope.interface.ts new file mode 100644 index 000000000..97d72902f --- /dev/null +++ b/src/app/features/organization/features/interventions/models/intervention-inventory/intervention-inventory-scope.interface.ts @@ -0,0 +1,52 @@ +/** + * Interface InterventionInventoryScope + * @interface InterventionInventoryScope + * + * @description + * Captures account, organization, intervention and authentication session before asynchronous work. + */ +export interface InterventionInventoryScope { + /** + * Property organizationId + * @readonly + * + * @description + * Owning organization UUID. + * + * @type {string} + */ + readonly organizationId: string; + + /** + * Property interventionId + * @readonly + * + * @description + * Current intervention UUID. + * + * @type {string} + */ + readonly interventionId: string; + + /** + * Property accountId + * @readonly + * + * @description + * Authenticated user owning the device data. + * + * @type {string} + */ + readonly accountId: string; + + /** + * Property sessionRevision + * @readonly + * + * @description + * Session generation, independent from ordinary bearer refresh. + * + * @type {number} + */ + readonly sessionRevision: number; +} diff --git a/src/app/features/organization/features/interventions/models/intervention-inventory/intervention-inventory-snapshot.interface.ts b/src/app/features/organization/features/interventions/models/intervention-inventory/intervention-inventory-snapshot.interface.ts new file mode 100644 index 000000000..a5aa8d1ab --- /dev/null +++ b/src/app/features/organization/features/interventions/models/intervention-inventory/intervention-inventory-snapshot.interface.ts @@ -0,0 +1,125 @@ +import type { + InventoryPartOutput, + InventoryWarehouseOutput, + InventoryConsumptionOutput, +} from '@features/organization/features/inventory/models'; + +/** + * Interface InterventionInventorySnapshot + * @interface InterventionInventorySnapshot + * + * @description + * Authorized quantitative references and declarations stored with one account-owned workspace. + * Completion flags distinguish full server pages from a retained single declaration receipt. + */ +export interface InterventionInventorySnapshot { + /** + * Property version + * @readonly + * + * @description + * Compatible metadata schema without replacing the existing IndexedDB database. + * + * @type {1} + */ + readonly version: 1; + + /** + * Property accountId + * @readonly + * + * @description + * Authenticated owner of this private device snapshot. + * + * @type {string} + */ + readonly accountId: string; + + /** + * Property organizationId + * @readonly + * + * @description + * Organization whose authorized references were completely read. + * + * @type {string} + */ + readonly organizationId: string; + + /** + * Property interventionId + * @readonly + * + * @description + * Workspace owning the declarations and their catalog. + * + * @type {string} + */ + readonly interventionId: string; + + /** + * Property capturedAt + * @readonly + * + * @description + * Latest snapshot capture timestamp; restoring never changes its age. + * + * @type {string} + */ + readonly capturedAt: string; + + /** + * Property catalogComplete + * @readonly + * + * @description + * True only after both authorized catalogs have been drained successfully. + * + * @type {boolean} + */ + readonly catalogComplete: boolean; + + /** + * Property declarationsComplete + * @readonly + * + * @description + * True only after the complete scoped declaration history was read. + * + * @type {boolean} + */ + readonly declarationsComplete: boolean; + + /** + * Property parts + * @readonly + * + * @description + * All authorized quantitative references captured at preparation. + * + * @type {readonly InventoryPartOutput[]} + */ + readonly parts: readonly InventoryPartOutput[]; + + /** + * Property warehouses + * @readonly + * + * @description + * All authorized warehouses captured at preparation. + * + * @type {readonly InventoryWarehouseOutput[]} + */ + readonly warehouses: readonly InventoryWarehouseOutput[]; + + /** + * Property declarations + * @readonly + * + * @description + * Confirmed and received-pending facts remain separate from the local outbox. + * + * @type {readonly InventoryConsumptionOutput[]} + */ + readonly declarations: readonly InventoryConsumptionOutput[]; +} diff --git a/src/app/features/organization/features/interventions/models/intervention-outbox/intervention-outbox-payload-map.interface.ts b/src/app/features/organization/features/interventions/models/intervention-outbox/intervention-outbox-payload-map.interface.ts index c0e58373a..41759fa72 100644 --- a/src/app/features/organization/features/interventions/models/intervention-outbox/intervention-outbox-payload-map.interface.ts +++ b/src/app/features/organization/features/interventions/models/intervention-outbox/intervention-outbox-payload-map.interface.ts @@ -1,6 +1,7 @@ import type { CreateEquipmentInput } from '@features/organization/features/equipments/models'; import type { CreateFacilityInput } from '@features/organization/features/facilities/models'; import type { CreateInspectionInput } from '@features/organization/features/inspections/models'; +import type { DeclareInventoryConsumptionInput } from '@features/organization/features/inventory/models'; import type { InterventionAttachmentKind } from '../intervention-attachment/intervention-attachment-kind.type'; import type { CreateInterventionChangeInput } from '../intervention-change/create-intervention-change-input.interface'; import type { UpdateInterventionChangeInput } from '../intervention-change/update-intervention-change-input.interface'; @@ -20,6 +21,25 @@ import type { InterventionStatus } from '../intervention/intervention-status.typ * @version 1.0.0 */ export interface InterventionOutboxPayloadMap { + /** + * Property 'inventory-consumption.declare' + * @readonly + * + * @description + * Immutable physical stock declaration; actor and stable operation identity survive replay. + * + * @access public + * + * @type {DeclareInventoryConsumptionInput & { + * readonly actorId: string; + * readonly clientId?: string; + * }} + */ + readonly 'inventory-consumption.declare': DeclareInventoryConsumptionInput & { + readonly actorId: string; + readonly clientId?: string; + }; + /** * Property 'time-entry.create' * @readonly diff --git a/src/app/features/organization/features/interventions/models/intervention-outbox/intervention-outbox-type.type.ts b/src/app/features/organization/features/interventions/models/intervention-outbox/intervention-outbox-type.type.ts index 62fe3dc4b..eb1763692 100644 --- a/src/app/features/organization/features/interventions/models/intervention-outbox/intervention-outbox-type.type.ts +++ b/src/app/features/organization/features/interventions/models/intervention-outbox/intervention-outbox-type.type.ts @@ -1,14 +1,16 @@ /** * Type InterventionOutboxType - * @type InterventionOutboxType * * @description * Supported operation types queued in the intervention offline outbox. * * @version 1.0.0 + * + * @type InterventionOutboxType */ export type InterventionOutboxType = | 'facility.create' + | 'inventory-consumption.declare' | 'equipment.create' | 'inspection.create' | 'media.create' diff --git a/src/app/features/organization/features/interventions/models/intervention-replacement-context/intervention-replacement-context-request.interface.ts b/src/app/features/organization/features/interventions/models/intervention-replacement-context/intervention-replacement-context-request.interface.ts new file mode 100644 index 000000000..01a8cb04f --- /dev/null +++ b/src/app/features/organization/features/interventions/models/intervention-replacement-context/intervention-replacement-context-request.interface.ts @@ -0,0 +1,47 @@ +/** + * Interface InterventionReplacementContextRequest + * @interface InterventionReplacementContextRequest + * + * @description + * Identifies the captured task and original equipment whose published successor is required. + */ +export interface InterventionReplacementContextRequest { + /** + * Property organizationId + * @readonly + * + * @description + * Organization whose equipment and work-item context own the replacement query. + * + * @access public + * + * @type {string} + */ + readonly organizationId: string; + + /** + * Property equipmentId + * @readonly + * + * @description + * Canonical original equipment UUID captured before loading replacement facts. + * + * @access public + * + * @type {string} + */ + readonly equipmentId: string; + + /** + * Property workItemId + * @readonly + * + * @description + * Work item to which the verified replacement result will be attached. + * + * @access public + * + * @type {string} + */ + readonly workItemId: string; +} diff --git a/src/app/features/organization/features/interventions/models/intervention-replacement-context/intervention-replacement-context.interface.ts b/src/app/features/organization/features/interventions/models/intervention-replacement-context/intervention-replacement-context.interface.ts new file mode 100644 index 000000000..ef9aec591 --- /dev/null +++ b/src/app/features/organization/features/interventions/models/intervention-replacement-context/intervention-replacement-context.interface.ts @@ -0,0 +1,36 @@ +import type { EquipmentOutput } from '@features/organization/features/equipments/models'; + +/** + * Interface InterventionReplacementContext + * @interface InterventionReplacementContext + * + * @description + * Server equipment facts used to confirm a completed replacement without accepting free-form links. + */ +export interface InterventionReplacementContext { + /** + * Property original + * @readonly + * + * @description + * Equipment read through its organization-scoped resource. + * + * @access public + * + * @type {EquipmentOutput} + */ + readonly original: EquipmentOutput; + + /** + * Property successor + * @readonly + * + * @description + * Published successor with a verified reciprocal link, or null when confirmation is unavailable. + * + * @access public + * + * @type {EquipmentOutput | null} + */ + readonly successor: EquipmentOutput | null; +} diff --git a/src/app/features/organization/features/interventions/models/intervention-table-query/intervention-collections-change.interface.ts b/src/app/features/organization/features/interventions/models/intervention-table-query/intervention-collections-change.interface.ts index 4072cc77c..f15816f8f 100644 --- a/src/app/features/organization/features/interventions/models/intervention-table-query/intervention-collections-change.interface.ts +++ b/src/app/features/organization/features/interventions/models/intervention-table-query/intervention-collections-change.interface.ts @@ -64,6 +64,7 @@ export interface InterventionCollectionsChange { | 'inspections' | 'activity' | 'attachments' + | 'consumptions' )[]; /** diff --git a/src/app/features/organization/features/interventions/models/intervention-tag/intervention-tag.util.ts b/src/app/features/organization/features/interventions/models/intervention-tag/intervention-tag.util.ts index d5cbd1603..b0a42f4ce 100644 --- a/src/app/features/organization/features/interventions/models/intervention-tag/intervention-tag.util.ts +++ b/src/app/features/organization/features/interventions/models/intervention-tag/intervention-tag.util.ts @@ -15,8 +15,10 @@ import type { InterventionTagDescriptor } from './intervention-tag-descriptor.in import type { InterventionTagKind } from './intervention-tag-kind.type'; /** - * Priority descriptors (low → urgent). + * Constant PRIORITY * + * @description + * Priority descriptors (low → urgent). * One glyph family — a chevron trending from down to up, a flat minus for * "normal" (no direction, the baseline), and a double chevron for the one * level past "high" — so all four share the same stroke weight and @@ -46,8 +48,10 @@ const PRIORITY: Record = { }; /** - * Workflow status descriptors (draft → published). + * Constant STATUS * + * @description + * Workflow status descriptors (draft → published). * Severity code: only the two end states carry colour — `success` for the * positive outcome (published), `danger` for the destructive one * (abandoned). Every in-between workflow step is `neutral`, so colour reads @@ -92,9 +96,11 @@ const STATUS: Record = { }; /** - * Intervention objective descriptors. + * Constant TYPE * - * A category, not a state: all three stay neutral and the glyph alone tells + * @description + * Intervention objective descriptors. + * A category, not a state: every objective stays neutral and the glyph alone tells * them apart, so chroma keeps meaning "something needs attention". */ const TYPE: Record = { @@ -113,11 +119,23 @@ const TYPE: Record = { severity: 'neutral', icon: 'lucideClipboardCheck', }, + preventive_maintenance: { + label: $localize`:@@intervention.type.preventiveMaintenance:Preventive maintenance`, + severity: 'neutral', + icon: 'lucideCalendar', + }, + corrective_maintenance: { + label: $localize`:@@intervention.type.correctiveMaintenance:Corrective maintenance`, + severity: 'neutral', + icon: 'lucideWrench', + }, }; /** - * Work item action descriptors. + * Constant WORK_ITEM_ACTION * + * @description + * Work item action descriptors. * A category like the objective above — neutral, told apart by its glyph. */ const WORK_ITEM_ACTION: Record = { @@ -136,11 +154,28 @@ const WORK_ITEM_ACTION: Record = { blocker: { label: $localize`:@@issueSeverity.blocker:Blocker`, @@ -187,8 +227,10 @@ const ISSUE_SEVERITY: Record }, }; -/** Inspection result descriptors. */ +/** + * Constant INSPECTION_RESULT + * + * @description + * Inspection result descriptors. + */ const INSPECTION_RESULT: Record = { pass: { label: $localize`:@@inspectionResult.pass:Pass`, @@ -229,7 +276,12 @@ const INSPECTION_RESULT: Record = { }, }; -/** Inspection status descriptors. */ +/** + * Constant INSPECTION_STATUS + * + * @description + * Inspection status descriptors. + */ const INSPECTION_STATUS: Record = { draft: { label: $localize`:@@inspectionStatus.draft:Draft`, @@ -253,7 +305,12 @@ const INSPECTION_STATUS: Record = { }, }; -/** Facility lifecycle status descriptors. */ +/** + * Constant FACILITY_STATUS + * + * @description + * Facility lifecycle status descriptors. + */ const FACILITY_STATUS: Record = { active: { label: $localize`:@@facilityStatus.active:Active`, @@ -267,7 +324,12 @@ const FACILITY_STATUS: Record = { }, }; -/** Equipment lifecycle status descriptors. */ +/** + * Constant EQUIPMENT_STATUS + * + * @description + * Equipment lifecycle status descriptors. + */ const EQUIPMENT_STATUS: Record = { in_stock: { label: $localize`:@@equipmentStatus.inStock:In stock`, @@ -291,7 +353,12 @@ const EQUIPMENT_STATUS: Record = { }, }; -/** Registry indexed by tag kind. */ +/** + * Constant REGISTRY + * + * @description + * Registry indexed by tag kind. + */ const REGISTRY: Record> = { priority: PRIORITY, status: STATUS, @@ -311,7 +378,6 @@ const REGISTRY: Record; diff --git a/src/app/features/organization/features/interventions/models/intervention-time/intervention-time-journal-view.interface.ts b/src/app/features/organization/features/interventions/models/intervention-time/intervention-time-journal-view.interface.ts index 81311007b..f07b3ff76 100644 --- a/src/app/features/organization/features/interventions/models/intervention-time/intervention-time-journal-view.interface.ts +++ b/src/app/features/organization/features/interventions/models/intervention-time/intervention-time-journal-view.interface.ts @@ -66,4 +66,56 @@ export interface InterventionTimeJournalView { * @type {boolean} */ readonly historyUnavailable: boolean; + + /** + * Property page + * @readonly + * + * @description + * Requested journal page, including an unavailable offline page. + * + * @access public + * + * @type {number} + */ + readonly page: number; + + /** + * Property itemsPerPage + * @readonly + * + * @description + * Bounded journal page size. + * + * @access public + * + * @type {number} + */ + readonly itemsPerPage: number; + + /** + * Property totalItems + * @readonly + * + * @description + * Saved entry count across pages; null when no authorized page metadata is available. + * + * @access public + * + * @type {number | null} + */ + readonly totalItems: number | null; + + /** + * Property nextPage + * @readonly + * + * @description + * Known next journal page, or null when unavailable or complete. + * + * @access public + * + * @type {number | null} + */ + readonly nextPage: number | null; } diff --git a/src/app/features/organization/features/interventions/models/intervention-time/intervention-time-scope.interface.ts b/src/app/features/organization/features/interventions/models/intervention-time/intervention-time-scope.interface.ts index a44bdf958..93300b3fb 100644 --- a/src/app/features/organization/features/interventions/models/intervention-time/intervention-time-scope.interface.ts +++ b/src/app/features/organization/features/interventions/models/intervention-time/intervention-time-scope.interface.ts @@ -49,4 +49,17 @@ export interface InterventionTimeScope { * @type {string} */ readonly actorId: string; + + /** + * Property manageOthers + * @readonly + * + * @description + * Current server capability; partitions broad journal snapshots from beneficiary-only pages. + * + * @access public + * + * @type {boolean} + */ + readonly manageOthers?: boolean; } diff --git a/src/app/features/organization/features/interventions/models/intervention-work-item/create-intervention-work-item-input.interface.ts b/src/app/features/organization/features/interventions/models/intervention-work-item/create-intervention-work-item-input.interface.ts index 0fa99ba7d..41392000d 100644 --- a/src/app/features/organization/features/interventions/models/intervention-work-item/create-intervention-work-item-input.interface.ts +++ b/src/app/features/organization/features/interventions/models/intervention-work-item/create-intervention-work-item-input.interface.ts @@ -1,4 +1,5 @@ import type { InterventionWorkItemAction } from './intervention-work-item-action.type'; +import type { InterventionWorkItemOperationKind } from './intervention-work-item-operation-kind.type'; import type { InterventionWorkItemSource } from './intervention-work-item-source.type'; /** @@ -11,6 +12,45 @@ import type { InterventionWorkItemSource } from './intervention-work-item-source * @version 1.0.0 */ export interface CreateInterventionWorkItemInput { + /** + * Property operationId + * @readonly + * + * @description + * Independent preventive operation identifier, when the task fulfills one. + * + * @access public + * + * @type {string | null | undefined} + */ + readonly operationId?: string | null; + + /** + * Property occurrenceId + * @readonly + * + * @description + * Preventive occurrence identifier associated with this task. + * + * @access public + * + * @type {string | null | undefined} + */ + readonly occurrenceId?: string | null; + + /** + * Property operationKind + * @readonly + * + * @description + * Preventive operation category, independent of the work-item action. + * + * @access public + * + * @type {InterventionWorkItemOperationKind | null | undefined} + */ + readonly operationKind?: InterventionWorkItemOperationKind | null; + /** * Property estimatedMinutes * @readonly diff --git a/src/app/features/organization/features/interventions/models/intervention-work-item/intervention-work-item-action.type.ts b/src/app/features/organization/features/interventions/models/intervention-work-item/intervention-work-item-action.type.ts index e1ee46697..f2c332c0a 100644 --- a/src/app/features/organization/features/interventions/models/intervention-work-item/intervention-work-item-action.type.ts +++ b/src/app/features/organization/features/interventions/models/intervention-work-item/intervention-work-item-action.type.ts @@ -1,4 +1,15 @@ /** - * Supported intervention work item actions. + * Type InterventionWorkItemAction + * + * @description + * Identifies the planned operation or discovery represented by an intervention work item. + * + * @type {InterventionWorkItemAction} */ -export type InterventionWorkItemAction = 'site_setup' | 'inventory' | 'inspection'; +export type InterventionWorkItemAction = + | 'site_setup' + | 'inventory' + | 'inspection' + | 'maintenance' + | 'repair' + | 'replacement'; diff --git a/src/app/features/organization/features/interventions/models/intervention-work-item/intervention-work-item-execution-outcome.type.ts b/src/app/features/organization/features/interventions/models/intervention-work-item/intervention-work-item-execution-outcome.type.ts new file mode 100644 index 000000000..d992bf37d --- /dev/null +++ b/src/app/features/organization/features/interventions/models/intervention-work-item/intervention-work-item-execution-outcome.type.ts @@ -0,0 +1,9 @@ +/** + * Type InterventionWorkItemExecutionOutcome + * + * @description + * Records the actual outcome of maintenance, repair or replacement work. + * + * @type {InterventionWorkItemExecutionOutcome} + */ +export type InterventionWorkItemExecutionOutcome = 'successful' | 'failed' | 'performed'; diff --git a/src/app/features/organization/features/interventions/models/intervention-work-item/intervention-work-item-execution-result-input.interface.ts b/src/app/features/organization/features/interventions/models/intervention-work-item/intervention-work-item-execution-result-input.interface.ts new file mode 100644 index 000000000..1fe2ac830 --- /dev/null +++ b/src/app/features/organization/features/interventions/models/intervention-work-item/intervention-work-item-execution-result-input.interface.ts @@ -0,0 +1,62 @@ +import type { InterventionWorkItemExecutionOutcome } from './intervention-work-item-execution-outcome.type'; + +/** + * Interface InterventionWorkItemExecutionResultInput + * @interface InterventionWorkItemExecutionResultInput + * + * @description + * Captures work actually performed, independently of synchronization and publication dates. + */ +export interface InterventionWorkItemExecutionResultInput { + /** + * Property equipmentId + * @readonly + * + * @description + * UUID of the equipment on which the work was performed. + * + * @access public + * + * @type {string} + */ + readonly equipmentId: string; + + /** + * Property performedAt + * @readonly + * + * @description + * Operator-declared execution instant with its explicit timezone offset. + * + * @access public + * + * @type {string} + */ + readonly performedAt: string; + + /** + * Property outcome + * @readonly + * + * @description + * Actual execution outcome, including failed work that still needs attention. + * + * @access public + * + * @type {InterventionWorkItemExecutionOutcome} + */ + readonly outcome: InterventionWorkItemExecutionOutcome; + + /** + * Property workPerformed + * @readonly + * + * @description + * Operator's description of the work actually executed. + * + * @access public + * + * @type {string} + */ + readonly workPerformed: string; +} diff --git a/src/app/features/organization/features/interventions/models/intervention-work-item/intervention-work-item-execution-result-output.interface.ts b/src/app/features/organization/features/interventions/models/intervention-work-item/intervention-work-item-execution-result-output.interface.ts new file mode 100644 index 000000000..438a7374e --- /dev/null +++ b/src/app/features/organization/features/interventions/models/intervention-work-item/intervention-work-item-execution-result-output.interface.ts @@ -0,0 +1,90 @@ +import type { InterventionWorkItemExecutionResultInput } from './intervention-work-item-execution-result-input.interface'; +import type { InterventionWorkItemExecutionResultState } from './intervention-work-item-execution-result-state.type'; + +/** + * Interface InterventionWorkItemExecutionResultOutput + * @interface InterventionWorkItemExecutionResultOutput + * + * @description + * Recorded execution result and its publication provenance. Local projections omit authorId + * until the server confirms the recording member. + */ +export interface InterventionWorkItemExecutionResultOutput extends InterventionWorkItemExecutionResultInput { + /** + * Property history + * @readonly + * + * @description + * Prior recorded attempts in chronological order, without recursively nested histories. + * + * @access public + * + * @type {readonly Omit[] | undefined} + */ + readonly history?: readonly Omit[]; + + /** + * Property authorId + * @readonly + * + * @description + * Recording organization member identifier, absent before server acknowledgement. + * + * @access public + * + * @type {string | undefined} + */ + readonly authorId?: string; + + /** + * Property operationId + * @readonly + * + * @description + * Independent preventive operation, when this work fulfills one. + * + * @access public + * + * @type {string | null} + */ + readonly operationId: string | null; + + /** + * Property occurrenceId + * @readonly + * + * @description + * Identified preventive occurrence fulfilled by this execution. + * + * @access public + * + * @type {string | null} + */ + readonly occurrenceId: string | null; + + /** + * Property state + * @readonly + * + * @description + * Validation state of the result, independent of the work item's completion status. + * + * @access public + * + * @type {InterventionWorkItemExecutionResultState} + */ + readonly state: InterventionWorkItemExecutionResultState; + + /** + * Property validatedAt + * @readonly + * + * @description + * Publication validation instant, absent until validation succeeds. + * + * @access public + * + * @type {string | null} + */ + readonly validatedAt: string | null; +} diff --git a/src/app/features/organization/features/interventions/models/intervention-work-item/intervention-work-item-execution-result-state.type.ts b/src/app/features/organization/features/interventions/models/intervention-work-item/intervention-work-item-execution-result-state.type.ts new file mode 100644 index 000000000..6667f3775 --- /dev/null +++ b/src/app/features/organization/features/interventions/models/intervention-work-item/intervention-work-item-execution-result-state.type.ts @@ -0,0 +1,9 @@ +/** + * Type InterventionWorkItemExecutionResultState + * + * @description + * Distinguishes a recorded result awaiting publication from a validated result. + * + * @type {InterventionWorkItemExecutionResultState} + */ +export type InterventionWorkItemExecutionResultState = 'staged' | 'validated'; diff --git a/src/app/features/organization/features/interventions/models/intervention-work-item/intervention-work-item-operation-kind.type.ts b/src/app/features/organization/features/interventions/models/intervention-work-item/intervention-work-item-operation-kind.type.ts new file mode 100644 index 000000000..07be335dc --- /dev/null +++ b/src/app/features/organization/features/interventions/models/intervention-work-item/intervention-work-item-operation-kind.type.ts @@ -0,0 +1,9 @@ +/** + * Type InterventionWorkItemOperationKind + * + * @description + * Identifies the independent preventive operation represented by a work item. + * + * @type {InterventionWorkItemOperationKind} + */ +export type InterventionWorkItemOperationKind = 'control' | 'maintenance'; diff --git a/src/app/features/organization/features/interventions/models/intervention-work-item/intervention-work-item-output.interface.ts b/src/app/features/organization/features/interventions/models/intervention-work-item/intervention-work-item-output.interface.ts index 2a010b0d4..f19e86240 100644 --- a/src/app/features/organization/features/interventions/models/intervention-work-item/intervention-work-item-output.interface.ts +++ b/src/app/features/organization/features/interventions/models/intervention-work-item/intervention-work-item-output.interface.ts @@ -1,6 +1,8 @@ import type { HydraItem } from '@core/api/models'; import type { InterventionWorkItemAction } from './intervention-work-item-action.type'; import type { InterventionWorkItemAssignee } from './intervention-work-item-assignee.interface'; +import type { InterventionWorkItemExecutionResultOutput } from './intervention-work-item-execution-result-output.interface'; +import type { InterventionWorkItemOperationKind } from './intervention-work-item-operation-kind.type'; import type { InterventionWorkItemSource } from './intervention-work-item-source.type'; import type { InterventionWorkItemStatus } from './intervention-work-item-status.type'; import type { InterventionWorkItemTarget } from './intervention-work-item-target.interface'; @@ -13,6 +15,58 @@ import type { InterventionWorkItemTarget } from './intervention-work-item-target * Defines the intervention work item output contract. */ export interface InterventionWorkItemOutput extends HydraItem { + /** + * Property executionResult + * @readonly + * + * @description + * Recorded execution and validation provenance, absent on historical snapshots. + * + * @access public + * + * @type {InterventionWorkItemExecutionResultOutput | null | undefined} + */ + readonly executionResult?: InterventionWorkItemExecutionResultOutput | null; + + /** + * Property operationId + * @readonly + * + * @description + * Independent preventive operation identifier, absent on legacy work items. + * + * @access public + * + * @type {string | null | undefined} + */ + readonly operationId?: string | null; + + /** + * Property occurrenceId + * @readonly + * + * @description + * Preventive occurrence identifier associated with this work item. + * + * @access public + * + * @type {string | null | undefined} + */ + readonly occurrenceId?: string | null; + + /** + * Property operationKind + * @readonly + * + * @description + * Preventive operation category, independent of the work-item action. + * + * @access public + * + * @type {InterventionWorkItemOperationKind | null | undefined} + */ + readonly operationKind?: InterventionWorkItemOperationKind | null; + /** * Property estimatedMinutes * @readonly diff --git a/src/app/features/organization/features/interventions/models/intervention-work-item/update-intervention-work-item-input.interface.ts b/src/app/features/organization/features/interventions/models/intervention-work-item/update-intervention-work-item-input.interface.ts index c6097ed68..de5ab3efc 100644 --- a/src/app/features/organization/features/interventions/models/intervention-work-item/update-intervention-work-item-input.interface.ts +++ b/src/app/features/organization/features/interventions/models/intervention-work-item/update-intervention-work-item-input.interface.ts @@ -1,3 +1,4 @@ +import type { InterventionWorkItemExecutionResultInput } from './intervention-work-item-execution-result-input.interface'; import type { InterventionWorkItemStatus } from './intervention-work-item-status.type'; /** @@ -10,6 +11,19 @@ import type { InterventionWorkItemStatus } from './intervention-work-item-status * @version 1.0.0 */ export interface UpdateInterventionWorkItemInput { + /** + * Property executionResult + * @readonly + * + * @description + * Explicit result of maintenance, repair or replacement performed on the target equipment. + * + * @access public + * + * @type {InterventionWorkItemExecutionResultInput | null | undefined} + */ + readonly executionResult?: InterventionWorkItemExecutionResultInput | null; + /** * Property estimatedMinutes * @readonly diff --git a/src/app/features/organization/features/interventions/models/intervention/intervention-output.interface.ts b/src/app/features/organization/features/interventions/models/intervention/intervention-output.interface.ts index c8aef85e9..d9781a6ef 100644 --- a/src/app/features/organization/features/interventions/models/intervention/intervention-output.interface.ts +++ b/src/app/features/organization/features/interventions/models/intervention/intervention-output.interface.ts @@ -1,4 +1,5 @@ import type { HydraItem } from '@core/api/models'; +import type { InterventionClosureSnapshotOutput } from '../intervention-closure/intervention-closure-snapshot-output.interface'; import type { InterventionLabelSummary } from '../intervention-label/intervention-label-summary.interface'; import type { InterventionAllowedActionsOutput } from './intervention-allowed-actions-output.interface'; import type { InterventionPriority } from './intervention-priority.type'; @@ -14,6 +15,19 @@ import type { InterventionType } from './intervention-type.type'; */ export interface InterventionOutput extends HydraItem { //#region Properties + /** + * Property closureSnapshot + * @readonly + * + * @description + * Immutable published dossier, absent on drafts and historical publications without a snapshot. + * + * @access public + * + * @type {InterventionClosureSnapshotOutput | null | undefined} + */ + readonly closureSnapshot?: InterventionClosureSnapshotOutput | null; + /** * Property id * @readonly diff --git a/src/app/features/organization/features/interventions/models/intervention/intervention-type.type.ts b/src/app/features/organization/features/interventions/models/intervention/intervention-type.type.ts index 74a40fa34..7e8b1e31e 100644 --- a/src/app/features/organization/features/interventions/models/intervention/intervention-type.type.ts +++ b/src/app/features/organization/features/interventions/models/intervention/intervention-type.type.ts @@ -1,4 +1,15 @@ /** - * Supported intervention workflow types. + * Type InterventionType + * + * @description + * Identifies the operational objective of an intervention, including preventive and corrective + * work. + * + * @type {InterventionType} */ -export type InterventionType = 'site_setup' | 'inventory' | 'inspection_campaign'; +export type InterventionType = + | 'site_setup' + | 'inventory' + | 'inspection_campaign' + | 'preventive_maintenance' + | 'corrective_maintenance'; diff --git a/src/app/features/organization/features/interventions/options/intervention-filter-options.constants.ts b/src/app/features/organization/features/interventions/options/intervention-filter-options.constants.ts index 821de6542..2d242200c 100644 --- a/src/app/features/organization/features/interventions/options/intervention-filter-options.constants.ts +++ b/src/app/features/organization/features/interventions/options/intervention-filter-options.constants.ts @@ -35,12 +35,18 @@ export const INTERVENTION_STATUS_FILTER_OPTIONS: SelectOption[] = ( - ['site_setup', 'inventory', 'inspection_campaign'] satisfies readonly InterventionType[] + [ + 'site_setup', + 'inventory', + 'inspection_campaign', + 'preventive_maintenance', + 'corrective_maintenance', + ] satisfies readonly InterventionType[] ).map((type) => ({ value: type, label: resolveInterventionTag('type', type).label })); /** @@ -102,7 +108,6 @@ export const INTERVENTION_SORT_OPTIONS: SelectOption[] = * once a field's own narrowing is cleared. Labels reuse the same `@@` ids the * select placeholders already carry, so nothing here duplicates translation * work. - * * `status`, `type`, `priority`, `site`, `responsible` and `label` each * declare `['equals', 'isAnyOf']` now that the API accepts a repeated * `key[]=` value OR-combined via `IN()` — `equals` stays first so it is diff --git a/src/app/features/organization/features/interventions/services/index.ts b/src/app/features/organization/features/interventions/services/index.ts index d0d2d6cbd..ab76eeb7d 100644 --- a/src/app/features/organization/features/interventions/services/index.ts +++ b/src/app/features/organization/features/interventions/services/index.ts @@ -1,4 +1,5 @@ /** + * @description * Interventions service public exports. */ export { BrowserDownloadService } from './browser-download'; @@ -13,6 +14,8 @@ export { InterventionOfflineLifecycleService } from './intervention-offline-life export { InterventionPhotoCompressorService } from './intervention-photo-compressor'; export { InterventionPwaUpdateService } from './intervention-pwa-update'; export { InterventionPrefetchService } from './intervention-prefetch'; +export { InterventionInventoryService } from './intervention-inventory'; export { InterventionQrScannerService } from './intervention-qr-scanner'; +export { InterventionReplacementContextService } from './intervention-replacement-context'; export { InterventionSyncService, interventionSyncEvents } from './intervention-sync'; export { InterventionSyncCoordinatorService } from './intervention-sync-coordinator'; diff --git a/src/app/features/organization/features/interventions/services/intervention-equipment-catalog/index.ts b/src/app/features/organization/features/interventions/services/intervention-equipment-catalog/index.ts new file mode 100644 index 000000000..40d6eed8a --- /dev/null +++ b/src/app/features/organization/features/interventions/services/intervention-equipment-catalog/index.ts @@ -0,0 +1 @@ +export { InterventionEquipmentCatalogService } from './intervention-equipment-catalog.service'; diff --git a/src/app/features/organization/features/interventions/services/intervention-equipment-catalog/intervention-equipment-catalog.service.ts b/src/app/features/organization/features/interventions/services/intervention-equipment-catalog/intervention-equipment-catalog.service.ts new file mode 100644 index 000000000..edd12fd51 --- /dev/null +++ b/src/app/features/organization/features/interventions/services/intervention-equipment-catalog/intervention-equipment-catalog.service.ts @@ -0,0 +1,232 @@ +import { inject, Service } from '@angular/core'; +import { catchError, defer, from, map, of, switchMap, type Observable } from 'rxjs'; +import { AUTH_SESSION_PORT } from '@features/auth/ports'; +import { OrganizationPermissionService } from '@features/organization/access'; +import type { EquipmentTypeCatalogStoreType } from '@features/organization/features/equipments'; +import { EquipmentTypeService } from '@features/organization/features/equipments/data-access'; +import { InterventionOfflineService } from '@features/organization/features/interventions/data-access'; +import type { + InterventionEquipmentCatalogSnapshot, + InterventionOutput, +} from '@features/organization/features/interventions/models'; +import { ORGANIZATION_PERMISSION } from '@features/organization/models'; +import { ORGANIZATION_CONTEXT_PORT } from '@features/organization/ports'; + +/** + * Class InterventionEquipmentCatalogService + * @class InterventionEquipmentCatalogService + * + * @description + * Captures the complete authorized Equipment-owned catalog for field work and restores it + * only while the current account, organization and permissions still match. + */ +@Service() +export class InterventionEquipmentCatalogService { + //#region Properties + /** + * Property catalog + * @readonly + * + * @description + * Public transport drains every server page, including archived type descriptors. + * + * @access private + * @since unreleased + * + * @type {EquipmentTypeService} + */ + private readonly catalog = inject(EquipmentTypeService); + + /** + * Property offline + * @readonly + * + * @description + * Account-fenced durable workspace storage. + * + * @access private + * @since unreleased + * + * @type {InterventionOfflineService} + */ + private readonly offline = inject(InterventionOfflineService); + + /** + * Property organization + * @readonly + * + * @description + * Current organization context owns the catalog authorization scope. + * + * @access private + * @since unreleased + * + * @type {OrganizationContextPort} + */ + private readonly organization = inject(ORGANIZATION_CONTEXT_PORT); + + /** + * Property permissions + * @readonly + * + * @description + * Current loaded permissions must still authorize reading equipment. + * + * @access private + * @since unreleased + * + * @type {OrganizationPermissionService} + */ + private readonly permissions = inject(OrganizationPermissionService); + + /** + * Property session + * @readonly + * + * @description + * Session replacement fences late catalog responses independently of bearer rotation. + * + * @access private + * @since unreleased + * + * @type {AuthSessionPort} + */ + private readonly session = inject(AUTH_SESSION_PORT); + //#endregion + + //#region Methods + /** + * Method accountId + * @method accountId + * + * @description + * Captures the authenticated account before workspace preparation crosses an await boundary. + * + * @access public + * @since unreleased + * + * @returns {string | null} Current account owner. + */ + public accountId(): string | null { + return this.offline.publicationOwner(); + } + + /** + * Method capture + * @method capture + * + * @description + * Persists a complete catalog only after its owning workspace has been saved. + * Missing permission, incomplete reads and stale scopes never produce a snapshot. + * + * @access public + * @since unreleased + * + * @param {InterventionOutput} intervention - Saved workspace owning this catalog snapshot. + * @param {string | null} expectedOwner - Account captured when workspace preparation started. + * + * @returns {Observable} Complete eligible catalog, + * or none. + */ + public capture( + intervention: InterventionOutput, + expectedOwner: string | null, + ): Observable { + return defer(() => { + const organizationId = /^\/api\/organizations\/([^/?#]+)$/.exec( + intervention.organization ?? '', + )?.[1]; + const revision = this.session.sessionRevision(); + if ( + !organizationId || + !expectedOwner || + !this.authorized(organizationId, expectedOwner, revision) + ) + return of(null); + return this.catalog.listAll(organizationId).pipe( + switchMap((entries) => { + if (!this.authorized(organizationId, expectedOwner, revision)) return of(null); + const snapshot: InterventionEquipmentCatalogSnapshot = { + version: 1, + accountId: expectedOwner, + organizationId, + entries, + capturedAt: new Date().toISOString(), + }; + return from( + this.offline.saveEquipmentCatalog( + intervention.id, + organizationId, + entries, + expectedOwner, + ), + ).pipe( + map(() => (this.authorized(organizationId, expectedOwner, revision) ? snapshot : null)), + ); + }), + catchError(() => of(null)), + ); + }); + } + + /** + * Method restore + * @method restore + * + * @description + * Hydrates a local page catalog while revocation and scope changes clear proprietary descriptors. + * + * @access public + * @since unreleased + * + * @param {InterventionEquipmentCatalogSnapshot | null | undefined} snapshot - Optional cached + * catalog; old workspaces omit it. + * @param {Pick} target - Equipment-owned page + * catalog instance. + * + * @returns {void} + */ + public restore( + snapshot: InterventionEquipmentCatalogSnapshot | null | undefined, + target: Pick, + ): void { + if ( + !snapshot || + snapshot.version !== 1 || + !this.authorized(snapshot.organizationId, snapshot.accountId) + ) { + target.clear(); + return; + } + target.seed(snapshot.organizationId, snapshot.entries); + } + + /** + * Method authorized + * @method authorized + * + * @description + * Rejects unloaded permissions and every stale account, organization or session. + * + * @access private + * @since unreleased + * + * @param {string} organizationId - Snapshot organization. + * @param {string} accountId - Snapshot account. + * @param {number} [sessionRevision] - Session captured before a network read. + * + * @returns {boolean} Whether this scope may currently expose the catalog. + */ + private authorized(organizationId: string, accountId: string, sessionRevision?: number): boolean { + return ( + this.session.isAuthenticated() && + this.offline.publicationOwner() === accountId && + this.organization.selectedOrganizationId() === organizationId && + !this.permissions.isLoadingPermissions() && + !this.permissions.permissionError() && + this.permissions.hasPermission(ORGANIZATION_PERMISSION.EQUIPMENT_READ) && + (sessionRevision === undefined || this.session.sessionRevision() === sessionRevision) + ); + } + //#endregion +} diff --git a/src/app/features/organization/features/interventions/services/intervention-equipment-catalog/testing/intervention-equipment-catalog.service.spec.ts b/src/app/features/organization/features/interventions/services/intervention-equipment-catalog/testing/intervention-equipment-catalog.service.spec.ts new file mode 100644 index 000000000..d6e14a66a --- /dev/null +++ b/src/app/features/organization/features/interventions/services/intervention-equipment-catalog/testing/intervention-equipment-catalog.service.spec.ts @@ -0,0 +1,162 @@ +import { signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { firstValueFrom, of, Subject, throwError } from 'rxjs'; +import { AUTH_SESSION_PORT } from '@features/auth/ports'; +import { OrganizationPermissionService } from '@features/organization/access'; +import { EquipmentTypeService } from '@features/organization/features/equipments/data-access'; +import type { EquipmentTypeOutput } from '@features/organization/features/equipments/models'; +import { InterventionOfflineService } from '@features/organization/features/interventions/data-access'; +import type { + InterventionEquipmentCatalogSnapshot, + InterventionOutput, +} from '@features/organization/features/interventions/models'; +import { ORGANIZATION_CONTEXT_PORT } from '@features/organization/ports'; +import { InterventionEquipmentCatalogService } from '../intervention-equipment-catalog.service'; + +const entry: EquipmentTypeOutput = { + '@id': '/api/organizations/org-1/equipment-types/custom_foam', + '@type': 'EquipmentType', + value: 'custom_foam', + label: 'Retired foam cabinet', + family: 'fire', + archived: true, + revision: 3, +}; +const intervention = { + id: 'intervention', + organization: '/api/organizations/org-1', +} as InterventionOutput; +const snapshot: InterventionEquipmentCatalogSnapshot = { + version: 1, + accountId: 'account', + organizationId: 'org-1', + capturedAt: '2026-10-06T10:00:00Z', + entries: [entry], +}; + +describe('InterventionEquipmentCatalogService', () => { + const owner = signal('account'); + const organization = signal('org-1'); + const authenticated = signal(true); + const sessionRevision = signal(1); + const allowed = signal(true); + const permissionsLoading = signal(false); + const permissionsError = signal(null); + const catalog = { listAll: vi.fn() }; + const offline = { publicationOwner: () => owner(), saveEquipmentCatalog: vi.fn() }; + const target = { seed: vi.fn(), clear: vi.fn() }; + + beforeEach(() => { + vi.resetAllMocks(); + owner.set('account'); + organization.set('org-1'); + authenticated.set(true); + sessionRevision.set(1); + allowed.set(true); + permissionsLoading.set(false); + permissionsError.set(null); + catalog.listAll.mockReturnValue(of([entry])); + offline.saveEquipmentCatalog.mockResolvedValue(undefined); + TestBed.configureTestingModule({ + providers: [ + InterventionEquipmentCatalogService, + { provide: EquipmentTypeService, useValue: catalog }, + { provide: InterventionOfflineService, useValue: offline }, + { + provide: AUTH_SESSION_PORT, + useValue: { isAuthenticated: authenticated, sessionRevision }, + }, + { provide: ORGANIZATION_CONTEXT_PORT, useValue: { selectedOrganizationId: organization } }, + { + provide: OrganizationPermissionService, + useValue: { + isLoadingPermissions: permissionsLoading, + permissionError: permissionsError, + hasPermission: () => allowed(), + }, + }, + ], + }); + }); + + it('captures the complete public catalog including archived labels', async () => { + const result = await firstValueFrom( + TestBed.inject(InterventionEquipmentCatalogService).capture(intervention, 'account'), + ); + expect(catalog.listAll).toHaveBeenCalledExactlyOnceWith('org-1'); + expect(offline.saveEquipmentCatalog).toHaveBeenCalledExactlyOnceWith( + 'intervention', + 'org-1', + [entry], + 'account', + ); + expect(result).toMatchObject({ + version: 1, + accountId: 'account', + organizationId: 'org-1', + entries: [entry], + }); + }); + + it('never turns a failed or incomplete catalog read into a saved catalog', async () => { + catalog.listAll.mockReturnValue(throwError(() => new Error('Second page unavailable'))); + expect( + await firstValueFrom( + TestBed.inject(InterventionEquipmentCatalogService).capture(intervention, 'account'), + ), + ).toBeNull(); + expect(offline.saveEquipmentCatalog).not.toHaveBeenCalled(); + }); + + it.each(['account', 'organization', 'session', 'permission'] as const)( + 'rejects a late read after the %s changes', + async (scope) => { + const pending = new Subject(); + catalog.listAll.mockReturnValue(pending); + const result = firstValueFrom( + TestBed.inject(InterventionEquipmentCatalogService).capture(intervention, 'account'), + ); + if (scope === 'account') owner.set('other-account'); + if (scope === 'organization') organization.set('org-2'); + if (scope === 'session') sessionRevision.set(2); + if (scope === 'permission') allowed.set(false); + pending.next([entry]); + pending.complete(); + expect(await result).toBeNull(); + expect(offline.saveEquipmentCatalog).not.toHaveBeenCalled(); + }, + ); + + it('restores archived descriptors and applies a newer cached label without reclassifying the code', () => { + const service = TestBed.inject(InterventionEquipmentCatalogService); + service.restore(snapshot, target); + expect(target.seed).toHaveBeenCalledWith('org-1', [entry]); + const updated = { ...entry, label: 'Archived foam cabinet — building A', revision: 4 }; + service.restore({ ...snapshot, entries: [updated] }, target); + expect(target.seed).toHaveBeenLastCalledWith('org-1', [updated]); + expect(updated.value).toBe(entry.value); + }); + + it('clears prior labels for legacy snapshots, changed accounts, organizations and revoked permission', () => { + const service = TestBed.inject(InterventionEquipmentCatalogService); + service.restore(undefined, target); + service.restore({ ...snapshot, accountId: 'other-account' }, target); + service.restore({ ...snapshot, organizationId: 'org-2' }, target); + allowed.set(false); + service.restore(snapshot, target); + expect(target.clear).toHaveBeenCalledTimes(4); + expect(target.seed).not.toHaveBeenCalled(); + }); + + it('does not read or restore while permissions are being refreshed or failed', async () => { + const service = TestBed.inject(InterventionEquipmentCatalogService); + permissionsLoading.set(true); + service.restore(snapshot, target); + expect(await firstValueFrom(service.capture(intervention, 'account'))).toBeNull(); + permissionsLoading.set(false); + permissionsError.set(new Error('Forbidden')); + service.restore(snapshot, target); + expect(catalog.listAll).not.toHaveBeenCalled(); + expect(target.seed).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/features/organization/features/interventions/services/intervention-field-execution/intervention-field-execution.service.ts b/src/app/features/organization/features/interventions/services/intervention-field-execution/intervention-field-execution.service.ts index ab910d224..f55967b9b 100644 --- a/src/app/features/organization/features/interventions/services/intervention-field-execution/intervention-field-execution.service.ts +++ b/src/app/features/organization/features/interventions/services/intervention-field-execution/intervention-field-execution.service.ts @@ -22,6 +22,7 @@ import type { InterventionDiscoveryResourcePlan, InterventionFieldDiscovery } fr * while delegating each business resource to its owning data-access service. * * @version 1.0.0 + * * @author Valentin FORTIN */ @Service() @@ -118,7 +119,8 @@ export class InterventionFieldExecutionService { * @param {InterventionFieldDiscovery} discovery - Field discovery. * @param {string} clientId - Stable client-generated resource identifier. * - * @return {InterventionDiscoveryResourcePlan} Prepared resource operation and canonical references. + * @returns {InterventionDiscoveryResourcePlan} Prepared resource operation and canonical + * references. */ public prepareDiscoveryResource( organizationId: string, @@ -158,6 +160,9 @@ export class InterventionFieldExecutionService { }; } + if (discovery.action !== 'inspection') { + throw new Error('Maintenance results must be recorded on their equipment work item'); + } const equipmentId = this.resourceId(discovery.target, 'equipment'); const payload: CreateInspectionInput = { clientId, @@ -189,7 +194,7 @@ export class InterventionFieldExecutionService { * * @param {File} file - Image containing the QR code. * - * @return {Promise} Scanned value when detected. + * @returns {Promise} Scanned value when detected. */ public scan(file: File): Promise { return this.scanner.scan(file); @@ -209,7 +214,8 @@ export class InterventionFieldExecutionService { * @since 4.6.0 * * @param {File} file - The captured QR image. - * @param {readonly InterventionWorkItemOutput[]} workItems - The intervention's current work items. + * @param {readonly InterventionWorkItemOutput[]} workItems - The intervention's current work + * items. * * @returns {Promise} The decode-and-match outcome. */ @@ -243,7 +249,7 @@ export class InterventionFieldExecutionService { * @param {string} equipmentId - Equipment identifier. * @param {File} source - Source evidence photo. * - * @return {Promise} Whether the photo was queued for synchronization. + * @returns {Promise} Whether the photo was queued for synchronization. */ public async attachPhoto( interventionId: string, @@ -276,7 +282,7 @@ export class InterventionFieldExecutionService { * @access public * @since 1.0.0 * - * @return {boolean} Whether QR scanning is supported. + * @returns {boolean} Whether QR scanning is supported. */ public scanSupported(): boolean { return this.scanner.isSupported(); diff --git a/src/app/features/organization/features/interventions/services/intervention-field-execution/testing/intervention-field-execution.service.spec.ts b/src/app/features/organization/features/interventions/services/intervention-field-execution/testing/intervention-field-execution.service.spec.ts index f0b504d63..633cad8f4 100644 --- a/src/app/features/organization/features/interventions/services/intervention-field-execution/testing/intervention-field-execution.service.spec.ts +++ b/src/app/features/organization/features/interventions/services/intervention-field-execution/testing/intervention-field-execution.service.spec.ts @@ -92,6 +92,21 @@ describe('InterventionFieldExecutionService', () => { }); }); + it.each(['maintenance', 'repair', 'replacement'] as const)( + 'never converts %s work into an inspection', + (action) => { + configureScanner(vi.fn()); + expect(() => + TestBed.inject(InterventionFieldExecutionService).prepareDiscoveryResource( + 'org-1', + 'intervention-1', + { action, target: '/api/equipment/equipment-1', result: 'pass' }, + 'client-1', + ), + ).toThrow('Maintenance results must be recorded on their equipment work item'); + }, + ); + it('prepares inspection evidence with a canonical equipment id and the observation time', () => { vi.useFakeTimers(); vi.setSystemTime(new Date('2026-09-22T10:00:00Z')); diff --git a/src/app/features/organization/features/interventions/services/intervention-inventory/index.ts b/src/app/features/organization/features/interventions/services/intervention-inventory/index.ts new file mode 100644 index 000000000..a51a08454 --- /dev/null +++ b/src/app/features/organization/features/interventions/services/intervention-inventory/index.ts @@ -0,0 +1 @@ +export { InterventionInventoryService } from './intervention-inventory.service'; diff --git a/src/app/features/organization/features/interventions/services/intervention-inventory/intervention-inventory.service.ts b/src/app/features/organization/features/interventions/services/intervention-inventory/intervention-inventory.service.ts new file mode 100644 index 000000000..3de2a50ac --- /dev/null +++ b/src/app/features/organization/features/interventions/services/intervention-inventory/intervention-inventory.service.ts @@ -0,0 +1,395 @@ +import { inject, Service } from '@angular/core'; +import { defer, firstValueFrom, type Observable } from 'rxjs'; +import type { HydraCollection, HydraItem } from '@core/api/models'; +import { AUTH_SESSION_PORT, type AuthSessionPort } from '@features/auth/ports'; +import { OrganizationPermissionService } from '@features/organization/access'; +import { + InterventionInventoryRepository, + InterventionOfflineService, +} from '@features/organization/features/interventions/data-access'; +import type { + InterventionInventoryScope, + InterventionInventorySnapshot, + InterventionOutput, +} from '@features/organization/features/interventions/models'; +import { InventoryService } from '@features/organization/features/inventory/data-access'; +import type { + InventoryPartOutput, + InventoryWarehouseOutput, + InventoryConsumptionOutput, +} from '@features/organization/features/inventory/models'; +import { ORGANIZATION_PERMISSION } from '@features/organization/models'; +import { + ORGANIZATION_CONTEXT_PORT, + type OrganizationContextPort, +} from '@features/organization/ports'; + +/** + * Constant INVENTORY_SNAPSHOT_LIMIT + * + * @description + * Bounds full reference and fact drains; exceeding it never produces a partial offline snapshot. + * + * @type {number} + */ +const INVENTORY_SNAPSHOT_LIMIT: number = 10000; + +/** + * Class InterventionInventoryService + * @class InterventionInventoryService + * + * @description + * Prepares full authorized quantitative references and declarations for an account-owned workspace. + * Ordinary snapshots expose no internal valuation and never claim global stock availability. + */ +@Service() +export class InterventionInventoryService { + //#region Properties + /** + * Property inventory + * @readonly + * + * @description + * Owner-published quantitative transport. + * + * @access private + * @since unreleased + * + * @type {InventoryService} + */ + private readonly inventory: InventoryService = inject(InventoryService); + + /** + * Property repository + * @readonly + * + * @description + * Retains complete catalog snapshots and accepted receipts. + * + * @access private + * @since unreleased + * + * @type {InterventionInventoryRepository} + */ + private readonly repository: InterventionInventoryRepository = inject( + InterventionInventoryRepository, + ); + + /** + * Property offline + * @readonly + * + * @description + * Supplies the account owner and saved workspace authority. + * + * @access private + * @since unreleased + * + * @type {InterventionOfflineService} + */ + private readonly offline: InterventionOfflineService = inject(InterventionOfflineService); + + /** + * Property session + * @readonly + * + * @description + * Authentication-session generation fences later network and persistence results. + * + * @access private + * @since unreleased + * + * @type {AuthSessionPort} + */ + private readonly session: AuthSessionPort = inject(AUTH_SESSION_PORT); + + /** + * Property organization + * @readonly + * + * @description + * Parent-owned current workspace context. + * + * @access private + * @since unreleased + * + * @type {OrganizationContextPort} + */ + private readonly organization: OrganizationContextPort = inject(ORGANIZATION_CONTEXT_PORT); + + /** + * Property permissions + * @readonly + * + * @description + * Unknown or revoked access cannot expose cached references. + * + * @access private + * @since unreleased + * + * @type {OrganizationPermissionService} + */ + private readonly permissions: OrganizationPermissionService = inject( + OrganizationPermissionService, + ); + //#endregion + + //#region Methods + /** + * Method scope + * @method scope + * + * @description + * Captures current account and session only after scope and Inventory read permission are known. + * + * @access public + * @since unreleased + * + * @param {string} organizationId - Owning organization. + * @param {string} interventionId - Current workspace. + * + * @returns {InterventionInventoryScope | null} Eligible captured scope. + */ + public scope(organizationId: string, interventionId: string): InterventionInventoryScope | null { + const accountId = this.offline.publicationOwner(); + if (!accountId) return null; + const scope: InterventionInventoryScope = { + organizationId, + interventionId, + accountId, + sessionRevision: this.session.sessionRevision(), + }; + return this.isCurrent(scope) ? scope : null; + } + + /** + * Method isCurrent + * @method isCurrent + * + * @description + * Requires current account, session, organization and loaded server permissions before exposure. + * + * @access public + * @since unreleased + * + * @param {InterventionInventoryScope} scope - Captured scope. + * @param {boolean} consume - Also require Inventory consumption and intervention execution. + * + * @returns {boolean} Whether this scope may still own the operation. + */ + public isCurrent(scope: InterventionInventoryScope, consume: boolean = false): boolean { + return ( + this.session.isAuthenticated() && + this.session.sessionRevision() === scope.sessionRevision && + this.offline.publicationOwner() === scope.accountId && + this.organization.selectedOrganizationId() === scope.organizationId && + !this.permissions.isLoadingPermissions() && + !this.permissions.permissionError() && + this.permissions.hasPermission(ORGANIZATION_PERMISSION.INVENTORY_READ) && + (!consume || + (this.permissions.hasPermission(ORGANIZATION_PERMISSION.INVENTORY_CONSUME) && + this.permissions.hasPermission(ORGANIZATION_PERMISSION.INTERVENTIONS_EXECUTE))) + ); + } + + /** + * Method loadSaved + * @method loadSaved + * + * @description + * Restores metadata only under currently authorized account and workspace access. + * + * @access public + * @since unreleased + * + * @param {InterventionInventoryScope} scope - Captured authorized scope. + * + * @returns {Promise} Saved complete or receipt-only + * snapshot. + */ + public loadSaved( + scope: InterventionInventoryScope, + ): Promise { + return this.repository.load(scope.organizationId, scope.interventionId, () => + this.isCurrent(scope), + ); + } + + /** + * Method capture + * @method capture + * + * @description + * Captures all authorized catalog pages after workspace preparation; failures never replace an + * earlier complete snapshot with a partial result. + * + * @access public + * @since unreleased + * + * @param {InterventionOutput} intervention - Canonical already-saved workspace. + * @param {string | null} expectedOwner - Account captured before preparation started. + * + * @returns {Observable} Complete snapshot, or none when + * denied. + */ + public capture( + intervention: InterventionOutput, + expectedOwner: string | null, + ): Observable { + return defer(async () => { + const organizationId = /^\/api\/organizations\/([^/?#]+)$/.exec( + intervention.organization ?? '', + )?.[1]; + const scope = organizationId ? this.scope(organizationId, intervention.id) : null; + if (!scope || expectedOwner !== scope.accountId) return null; + return this.refresh(scope); + }); + } + + /** + * Method refresh + * @method refresh + * + * @description + * Drains all server pages and then saves one complete scoped catalog and declaration history. + * + * @access public + * @since unreleased + * + * @param {InterventionInventoryScope} scope - Captured account and session. + * + * @returns {Promise} Persisted full snapshot. + */ + public async refresh(scope: InterventionInventoryScope): Promise { + const current = (): boolean => this.isCurrent(scope); + this.assertCurrent(current); + const [parts, warehouses, declarations] = await Promise.all([ + this.collectPages( + (page) => + this.inventory.listParts(scope.organizationId, { + page, + itemsPerPage: 100, + params: { archived: false }, + }), + current, + ), + this.collectPages( + (page) => + this.inventory.listWarehouses(scope.organizationId, { + page, + itemsPerPage: 100, + params: { archived: false }, + }), + current, + ), + this.collectPages( + (page) => + this.inventory.listConsumptions(scope.organizationId, { + page, + itemsPerPage: 100, + params: { interventionId: scope.interventionId }, + }), + current, + ), + ]); + this.assertCurrent(current); + if (declarations.some((item) => item.interventionId !== scope.interventionId)) + throw new Error( + $localize`:@@intervention.inventory.historyScopeError:The declaration history could not be verified for this intervention. Refresh before continuing.`, + ); + const snapshot: InterventionInventorySnapshot = { + version: 1, + ...scope, + capturedAt: new Date().toISOString(), + catalogComplete: true, + declarationsComplete: true, + parts, + warehouses, + declarations, + }; + await this.repository.save(snapshot, current); + this.assertCurrent(current); + const retained = await this.repository.load( + scope.organizationId, + scope.interventionId, + current, + ); + this.assertCurrent(current); + return retained ?? snapshot; + } + + /** + * Method collectPages + * @method collectPages + * + * @description + * Rejects missing pages, changing totals or repeated pages instead of labeling them complete. + * + * @access private + * @since unreleased + * + * @template Row - Server resource with a stable identifier. + * + * @param {(page: number) => Observable>} read - Authorized paged transport. + * @param {() => boolean} isCurrent - Authorization fence at every async boundary. + * + * @returns {Promise} All validated bounded rows. + */ + private async collectPages( + read: (page: number) => Observable>, + isCurrent: () => boolean, + ): Promise { + const rows = new Map(); + let total: number | null = null; + for (let page = 1; page <= INVENTORY_SNAPSHOT_LIMIT / 100 + 1; page += 1) { + this.assertCurrent(isCurrent); + // eslint-disable-next-line no-await-in-loop -- Each validated page determines whether another bounded request is safe. + const response = await firstValueFrom(read(page)); + this.assertCurrent(isCurrent); + if ( + !Array.isArray(response.member) || + !Number.isSafeInteger(response.totalItems) || + response.totalItems < 0 || + response.totalItems > INVENTORY_SNAPSHOT_LIMIT || + (total !== null && response.totalItems !== total) + ) + throw new Error( + $localize`:@@intervention.inventory.catalogIncomplete:The stock references changed or could not be downloaded completely. Retry preparation.`, + ); + total = response.totalItems; + const previousSize = rows.size; + for (const row of response.member) { + if (typeof row.id !== 'string' || !row.id) + throw new Error('A stock reference has no stable identity.'); + rows.set(row.id, row); + } + if (rows.size === total) return [...rows.values()]; + if (rows.size > total || rows.size === previousSize) + throw new Error( + $localize`:@@intervention.inventory.catalogIncomplete:The stock references changed or could not be downloaded completely. Retry preparation.`, + ); + } + throw new Error( + $localize`:@@intervention.inventory.catalogIncomplete:The stock references changed or could not be downloaded completely. Retry preparation.`, + ); + } + + /** + * Method assertCurrent + * @method assertCurrent + * + * @description + * Cancels obsolete exposure and persistence while an already accepted server request may finish. + * + * @access private + * @since unreleased + * + * @param {() => boolean} isCurrent - Captured authorization fence. + * + * @returns {void} Throws after an account, permission, organization or session change. + */ + private assertCurrent(isCurrent: () => boolean): void { + if (!isCurrent()) throw new DOMException('Inventory preparation scope changed.', 'AbortError'); + } + //#endregion +} diff --git a/src/app/features/organization/features/interventions/services/intervention-inventory/testing/intervention-inventory.service.spec.ts b/src/app/features/organization/features/interventions/services/intervention-inventory/testing/intervention-inventory.service.spec.ts new file mode 100644 index 000000000..aa7dd84f5 --- /dev/null +++ b/src/app/features/organization/features/interventions/services/intervention-inventory/testing/intervention-inventory.service.spec.ts @@ -0,0 +1,463 @@ +import { signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { firstValueFrom, of, Subject, throwError, type Observable } from 'rxjs'; +import type { HydraCollection, HydraItem, RequestOptions } from '@core/api/models'; +import { AUTH_SESSION_PORT } from '@features/auth/ports'; +import { OrganizationPermissionService } from '@features/organization/access'; +import { + InterventionInventoryRepository, + InterventionOfflineService, +} from '@features/organization/features/interventions/data-access'; +import type { + InterventionInventoryScope, + InterventionInventorySnapshot, + InterventionOutput, +} from '@features/organization/features/interventions/models'; +import { InventoryService } from '@features/organization/features/inventory/data-access'; +import type { + InventoryConsumptionOutput, + InventoryPartOutput, + InventoryWarehouseOutput, +} from '@features/organization/features/inventory/models'; +import { ORGANIZATION_PERMISSION } from '@features/organization/models'; +import { ORGANIZATION_CONTEXT_PORT } from '@features/organization/ports'; +import { InterventionInventoryService } from '../intervention-inventory.service'; + +const part: InventoryPartOutput = { + '@id': '/api/organizations/org-1/inventory-parts/part-1', + '@type': 'InventoryPart', + id: 'part-1', + code: 'SEAL', + label: 'Safety seal', + unit: 'piece', + kind: 'part', + archived: false, +}; +const warehouse: InventoryWarehouseOutput = { + '@id': '/api/organizations/org-1/inventory-warehouses/warehouse-1', + '@type': 'InventoryWarehouse', + id: 'warehouse-1', + code: 'VAN', + name: 'Technician van', + archived: false, +}; +const declaration: InventoryConsumptionOutput = { + '@id': '/api/organizations/org-1/inventory-consumptions/declaration-1', + '@type': 'InventoryConsumption', + id: 'declaration-1', + partId: 'part-1', + warehouseId: 'warehouse-1', + quantity: '2.000000', + interventionId: 'intervention-1', + actorId: 'account-1', + occurredAt: '2026-10-06T10:00:00Z', + status: 'received_pending', + reason: 'insufficient_stock', + late: false, + replayed: false, +}; +const intervention = { + id: 'intervention-1', + organization: '/api/organizations/org-1', +} as InterventionOutput; +const scope: InterventionInventoryScope = { + accountId: 'account-1', + organizationId: 'org-1', + interventionId: 'intervention-1', + sessionRevision: 1, +}; +const snapshot: InterventionInventorySnapshot = { + version: 1, + accountId: 'account-1', + organizationId: 'org-1', + interventionId: 'intervention-1', + capturedAt: '2026-10-06T10:00:00Z', + catalogComplete: true, + declarationsComplete: true, + parts: [part], + warehouses: [warehouse], + declarations: [declaration], +}; + +/** + * Function collection + * + * @description + * Builds paginated owner-published transport fixtures without bypassing their required Hydra shape. + * + * @template Row - Resource owned by the collection. + * + * @param {readonly Row[]} member - Rows on this page. + * @param {number} totalItems - Verified total across all pages. + * + * @returns {HydraCollection} Typed server page. + */ +function collection( + member: readonly Row[], + totalItems: number = member.length, +): HydraCollection { + return { '@id': '/api/collection', '@type': 'Collection', member, totalItems }; +} + +describe('InterventionInventoryService', () => { + let savedSnapshot: InterventionInventorySnapshot | null; + const owner = signal('account-1'); + const organization = signal('org-1'); + const authenticated = signal(true); + const sessionRevision = signal(1); + const permissionsLoading = signal(false); + const permissionsError = signal(null); + const permissions = signal>( + new Set([ + ORGANIZATION_PERMISSION.INVENTORY_READ, + ORGANIZATION_PERMISSION.INVENTORY_CONSUME, + ORGANIZATION_PERMISSION.INTERVENTIONS_EXECUTE, + ]), + ); + const inventory = { + listParts: + vi.fn< + ( + organizationId: string, + options: RequestOptions, + ) => Observable> + >(), + listWarehouses: + vi.fn< + ( + organizationId: string, + options: RequestOptions, + ) => Observable> + >(), + listConsumptions: + vi.fn< + ( + organizationId: string, + options: RequestOptions, + ) => Observable> + >(), + }; + const repository = { + load: vi.fn< + ( + organizationId: string, + interventionId: string, + current: () => boolean, + ) => Promise + >(), + save: vi.fn<(value: InterventionInventorySnapshot, current: () => boolean) => Promise>(), + }; + + beforeEach(() => { + vi.resetAllMocks(); + savedSnapshot = snapshot; + owner.set('account-1'); + organization.set('org-1'); + authenticated.set(true); + sessionRevision.set(1); + permissionsLoading.set(false); + permissionsError.set(null); + permissions.set( + new Set([ + ORGANIZATION_PERMISSION.INVENTORY_READ, + ORGANIZATION_PERMISSION.INVENTORY_CONSUME, + ORGANIZATION_PERMISSION.INTERVENTIONS_EXECUTE, + ]), + ); + inventory.listParts.mockReturnValue(of(collection([part]))); + inventory.listWarehouses.mockReturnValue(of(collection([warehouse]))); + inventory.listConsumptions.mockReturnValue(of(collection([declaration]))); + repository.load.mockImplementation(async (_organization, _intervention, current) => + current() ? savedSnapshot : null, + ); + repository.save.mockImplementation(async (value, current) => { + if (!current()) throw new DOMException('Snapshot scope changed.', 'AbortError'); + savedSnapshot = value; + }); + TestBed.configureTestingModule({ + providers: [ + InterventionInventoryService, + { provide: InventoryService, useValue: inventory }, + { provide: InterventionInventoryRepository, useValue: repository }, + { provide: InterventionOfflineService, useValue: { publicationOwner: () => owner() } }, + { + provide: AUTH_SESSION_PORT, + useValue: { isAuthenticated: authenticated, sessionRevision }, + }, + { provide: ORGANIZATION_CONTEXT_PORT, useValue: { selectedOrganizationId: organization } }, + { + provide: OrganizationPermissionService, + useValue: { + isLoadingPermissions: permissionsLoading, + permissionError: permissionsError, + hasPermission: (permission: string) => permissions().has(permission), + }, + }, + ], + }); + }); + + it('prepares every catalog and declaration page before saving one complete workspace snapshot', async () => { + const secondPart = { ...part, id: 'part-2', code: 'GAUGE', label: 'Pressure gauge' }; + const thirdPart = { ...part, id: 'part-3', code: 'HOSE', label: 'Hose' }; + const secondWarehouse = { ...warehouse, id: 'warehouse-2', code: 'DEPOT', name: 'Depot' }; + inventory.listParts.mockImplementation((_organization, options) => + of(collection([options.page === 1 ? part : options.page === 2 ? secondPart : thirdPart], 3)), + ); + inventory.listWarehouses.mockImplementation((_organization, options) => + of(collection([options.page === 1 ? warehouse : secondWarehouse], 2)), + ); + + const result = await TestBed.inject(InterventionInventoryService).refresh(scope); + + expect(result).toMatchObject({ + version: 1, + accountId: 'account-1', + organizationId: 'org-1', + interventionId: 'intervention-1', + catalogComplete: true, + declarationsComplete: true, + parts: [part, secondPart, thirdPart], + warehouses: [warehouse, secondWarehouse], + declarations: [declaration], + }); + expect(inventory.listParts.mock.calls).toEqual( + [1, 2, 3].map((page) => [ + 'org-1', + { + page, + itemsPerPage: 100, + params: { archived: false }, + }, + ]), + ); + expect(inventory.listWarehouses).toHaveBeenCalledTimes(2); + expect(inventory.listConsumptions).toHaveBeenCalledExactlyOnceWith('org-1', { + page: 1, + itemsPerPage: 100, + params: { interventionId: 'intervention-1' }, + }); + expect(repository.save).toHaveBeenCalledExactlyOnceWith(result, expect.any(Function)); + expect(repository.save.mock.calls[0]?.[1]()).toBe(true); + }); + + it('keeps a server-received declaration unresolved while catalog preparation completes', async () => { + const result = await TestBed.inject(InterventionInventoryService).refresh(scope); + expect(result.declarations).toEqual([declaration]); + expect(result.declarations[0]?.status).toBe('received_pending'); + expect(result.declarations[0]?.reason).toBe('insufficient_stock'); + }); + + it('exposes a concurrently accepted receipt from the durable merged snapshot after the network drain', async () => { + const concurrentlyAccepted: InventoryConsumptionOutput = { + ...declaration, + '@id': '/api/organizations/org-1/inventory-consumptions/declaration-2', + id: 'declaration-2', + quantity: '0.250000', + occurredAt: '2026-10-06T10:01:00Z', + }; + repository.save.mockImplementation(async (value, current) => { + expect(current()).toBe(true); + expect(value.declarations).toEqual([declaration]); + savedSnapshot = { + ...value, + declarations: [...value.declarations, concurrentlyAccepted], + }; + }); + + const result = await TestBed.inject(InterventionInventoryService).refresh(scope); + + expect(repository.load).toHaveBeenCalledExactlyOnceWith( + 'org-1', + 'intervention-1', + expect.any(Function), + ); + expect(result).toBe(savedSnapshot); + expect(result.declarations).toEqual([declaration, concurrentlyAccepted]); + expect(result.catalogComplete).toBe(true); + expect(result.declarations[1]?.status).toBe('received_pending'); + }); + + it('does not expose the merged snapshot when the session changes during its final durable read', async () => { + let release: ((retained: InterventionInventorySnapshot | null) => void) | undefined; + repository.load.mockImplementation( + () => + new Promise((resolve) => { + release = resolve; + }), + ); + const result = TestBed.inject(InterventionInventoryService).refresh(scope); + const rejected = expect(result).rejects.toMatchObject({ name: 'AbortError' }); + await vi.waitFor(() => expect(repository.load).toHaveBeenCalledOnce()); + sessionRevision.set(2); + expect(repository.load.mock.calls[0]?.[2]()).toBe(false); + release?.(savedSnapshot); + await rejected; + }); + + it('deduplicates overlapping pages only when the verified total is reached', async () => { + const secondPart = { ...part, id: 'part-2' }; + inventory.listParts.mockImplementation((_organization, options) => + of(collection(options.page === 1 ? [part] : [part, secondPart], 2)), + ); + const result = await TestBed.inject(InterventionInventoryService).refresh(scope); + expect(result.parts).toEqual([part, secondPart]); + expect(repository.save).toHaveBeenCalledOnce(); + }); + + it('rejects a repeated page without replacing the previously saved complete snapshot', async () => { + inventory.listParts.mockReturnValue(of(collection([part], 2))); + await expect(TestBed.inject(InterventionInventoryService).refresh(scope)).rejects.toThrow( + 'could not be downloaded completely', + ); + expect(inventory.listParts).toHaveBeenCalledTimes(2); + expect(repository.save).not.toHaveBeenCalled(); + }); + + it('rejects changed totals between pages instead of claiming a complete catalog', async () => { + inventory.listParts.mockImplementation((_organization, options) => + of( + collection( + options.page === 1 ? [part] : [{ ...part, id: 'part-2' }], + options.page === 1 ? 2 : 3, + ), + ), + ); + await expect(TestBed.inject(InterventionInventoryService).refresh(scope)).rejects.toThrow( + 'could not be downloaded completely', + ); + expect(repository.save).not.toHaveBeenCalled(); + }); + + it.each([-1, 1.5, 10001, Number.MAX_SAFE_INTEGER + 1])( + 'rejects an invalid or unbounded catalog total of %s', + async (totalItems) => { + inventory.listParts.mockReturnValue(of(collection([part], totalItems))); + await expect(TestBed.inject(InterventionInventoryService).refresh(scope)).rejects.toThrow( + 'could not be downloaded completely', + ); + expect(repository.save).not.toHaveBeenCalled(); + }, + ); + + it('preserves a failed page error and saves no partial catalog', async () => { + const failure = new Error('Warehouse page unavailable'); + inventory.listWarehouses.mockReturnValue(throwError(() => failure)); + await expect(TestBed.inject(InterventionInventoryService).refresh(scope)).rejects.toBe(failure); + expect(repository.save).not.toHaveBeenCalled(); + }); + + it('rejects a declaration belonging to another intervention before persisting its history', async () => { + inventory.listConsumptions.mockReturnValue( + of(collection([{ ...declaration, interventionId: 'intervention-2' }])), + ); + await expect(TestBed.inject(InterventionInventoryService).refresh(scope)).rejects.toThrow( + 'could not be verified for this intervention', + ); + expect(repository.save).not.toHaveBeenCalled(); + }); + + it.each(['account', 'organization', 'session', 'authentication', 'permission'] as const)( + 'does not save a late preparation after the %s changes', + async (change) => { + const pending = new Subject>(); + inventory.listParts.mockReturnValue(pending); + const result = TestBed.inject(InterventionInventoryService).refresh(scope); + const rejected = expect(result).rejects.toMatchObject({ name: 'AbortError' }); + if (change === 'account') owner.set('account-2'); + if (change === 'organization') organization.set('org-2'); + if (change === 'session') sessionRevision.set(2); + if (change === 'authentication') authenticated.set(false); + if (change === 'permission') permissions.set(new Set()); + pending.next(collection([part])); + pending.complete(); + await rejected; + expect(repository.save).not.toHaveBeenCalled(); + }, + ); + + it('fences persistence itself when access is revoked while the durable write is pending', async () => { + let release: (() => void) | undefined; + repository.save.mockImplementation( + () => + new Promise((resolve) => { + release = resolve; + }), + ); + const result = TestBed.inject(InterventionInventoryService).refresh(scope); + const rejected = expect(result).rejects.toMatchObject({ name: 'AbortError' }); + await vi.waitFor(() => expect(repository.save).toHaveBeenCalledOnce()); + sessionRevision.set(2); + expect(repository.save.mock.calls[0]?.[1]()).toBe(false); + release?.(); + await rejected; + }); + + it('requires both resource consumption and intervention execution for declarations', () => { + const service = TestBed.inject(InterventionInventoryService); + expect(service.isCurrent(scope, true)).toBe(true); + permissions.set( + new Set([ORGANIZATION_PERMISSION.INVENTORY_READ, ORGANIZATION_PERMISSION.INVENTORY_CONSUME]), + ); + expect(service.isCurrent(scope)).toBe(true); + expect(service.isCurrent(scope, true)).toBe(false); + permissions.set( + new Set([ + ORGANIZATION_PERMISSION.INVENTORY_READ, + ORGANIZATION_PERMISSION.INTERVENTIONS_EXECUTE, + ]), + ); + expect(service.isCurrent(scope, true)).toBe(false); + }); + + it.each(['loading', 'error', 'denied'] as const)( + 'does not expose cached references with %s permissions', + async (access) => { + const service = TestBed.inject(InterventionInventoryService); + if (access === 'loading') permissionsLoading.set(true); + if (access === 'error') permissionsError.set(new Error('Permission read unavailable')); + if (access === 'denied') permissions.set(new Set()); + expect(service.scope('org-1', 'intervention-1')).toBeNull(); + expect(await service.loadSaved(scope)).toBeNull(); + expect(repository.load.mock.calls[0]?.[2]()).toBe(false); + expect(await firstValueFrom(service.capture(intervention, 'account-1'))).toBeNull(); + expect(inventory.listParts).not.toHaveBeenCalled(); + }, + ); + + it('restores the saved snapshot through a live account and organization authorization guard', async () => { + const service = TestBed.inject(InterventionInventoryService); + expect(service.scope('org-1', 'intervention-1')).toEqual(scope); + expect(await service.loadSaved(scope)).toEqual(snapshot); + expect(repository.load).toHaveBeenCalledExactlyOnceWith( + 'org-1', + 'intervention-1', + expect.any(Function), + ); + owner.set('account-2'); + expect(repository.load.mock.calls[0]?.[2]()).toBe(false); + }); + + it('does not capture for another preparation owner or an unverified organization IRI', async () => { + const service = TestBed.inject(InterventionInventoryService); + expect(await firstValueFrom(service.capture(intervention, 'account-2'))).toBeNull(); + expect( + await firstValueFrom( + service.capture( + { ...intervention, organization: '/api/organizations/org-1?other-scope=true' }, + 'account-1', + ), + ), + ).toBeNull(); + expect(inventory.listParts).not.toHaveBeenCalled(); + expect(repository.save).not.toHaveBeenCalled(); + }); + + it('captures an authorized prepared intervention with all completion flags', async () => { + const result = await firstValueFrom( + TestBed.inject(InterventionInventoryService).capture(intervention, 'account-1'), + ); + expect(result).toMatchObject({ ...snapshot, capturedAt: expect.any(String) }); + expect(repository.save).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/app/features/organization/features/interventions/services/intervention-prefetch/intervention-prefetch.service.ts b/src/app/features/organization/features/interventions/services/intervention-prefetch/intervention-prefetch.service.ts index 5e298dbf4..7e7b087eb 100644 --- a/src/app/features/organization/features/interventions/services/intervention-prefetch/intervention-prefetch.service.ts +++ b/src/app/features/organization/features/interventions/services/intervention-prefetch/intervention-prefetch.service.ts @@ -1,5 +1,15 @@ import { effect, inject, Service, signal, type WritableSignal } from '@angular/core'; -import { catchError, EMPTY, forkJoin, from, map, mergeMap, type Observable, switchMap } from 'rxjs'; +import { + catchError, + EMPTY, + forkJoin, + from, + map, + mergeMap, + of, + type Observable, + switchMap, +} from 'rxjs'; import { ConnectivityService } from '@core/connectivity'; import { AUTH_SESSION_PORT, type AuthSessionPort } from '@features/auth/ports'; import { OrganizationMemberService } from '@features/organization/data-access'; @@ -11,6 +21,8 @@ import { } from '@features/organization/features/interventions/data-access'; import type { InterventionOutput } from '@features/organization/features/interventions/models'; import { ActiveOrganizationStore } from '@features/organization/state'; +import { InterventionEquipmentCatalogService } from '../intervention-equipment-catalog'; +import { InterventionInventoryService } from '../intervention-inventory'; /** * Service InterventionPrefetchService @@ -31,9 +43,13 @@ export class InterventionPrefetchService { /** * Property authSession * @readonly - * @description Prevents background requests before sign-in and cancels prefetch when the session ends. + * + * @description + * Prevents background requests before sign-in and cancels prefetch when the session ends. + * * @access private * @since 1.0.0 + * * @type {AuthSessionPort} */ private readonly authSession = inject(AUTH_SESSION_PORT); @@ -127,6 +143,34 @@ export class InterventionPrefetchService { InterventionOfflineService, ); + /** + * Property equipmentCatalog + * @readonly + * + * @description + * Captures the complete authorized catalog alongside each durable workspace. + * + * @access private + * @since unreleased + * + * @type {InterventionEquipmentCatalogService} + */ + private readonly equipmentCatalog = inject(InterventionEquipmentCatalogService); + + /** + * Property inventory + * @readonly + * + * @description + * Prepares authorized parts and warehouses after the workspace is durable. + * + * @access private + * @since unreleased + * + * @type {InterventionInventoryService} + */ + private readonly inventory: InterventionInventoryService = inject(InterventionInventoryService); + /** * Property members * @readonly @@ -180,11 +224,13 @@ export class InterventionPrefetchService { .getCurrentProfile(organizationId) .pipe( switchMap((profile) => - this.service.listAll(organizationId, { - responsible: `/api/organizations/${organizationId}/members/${profile.id}`, - }), + this.service + .listAll(organizationId, { + responsible: `/api/organizations/${organizationId}/members/${profile.id}`, + }) + .pipe(map((interventions) => ({ interventions, actorId: profile.id }))), ), - switchMap((interventions) => + switchMap(({ interventions, actorId }) => // Prefetch each workspace independently: a single failure must not // wipe out every other cached workspace (the old forkJoin was // all-or-nothing), so an offline-bound agent keeps the ones that @@ -195,7 +241,7 @@ export class InterventionPrefetchService { ), ).pipe( mergeMap((intervention) => - this.prefetch(organizationId, intervention).pipe(catchError(() => EMPTY)), + this.prefetch(organizationId, intervention, actorId).pipe(catchError(() => EMPTY)), ), ), ), @@ -237,10 +283,15 @@ export class InterventionPrefetchService { * * @param {string} organizationId - Organization owning the intervention. * @param {InterventionOutput} intervention - Intervention to prefetch. + * @param {string} actorId - Current beneficiary of actor-only journal pages. * * @returns {Observable} Completes once the workspace is persisted. */ - private prefetch(organizationId: string, intervention: InterventionOutput): Observable { + private prefetch( + organizationId: string, + intervention: InterventionOutput, + actorId: string, + ): Observable { const owner = this.offline.publicationOwner(); return forkJoin({ workItems: this.service.listAllWorkItems(intervention.id), @@ -248,10 +299,18 @@ export class InterventionPrefetchService { issues: this.service.listIssues(intervention.id), }).pipe( switchMap(({ workItems, changes, issues }) => { - if (this.organization.selectedOrganizationId() !== organizationId) return EMPTY; + if ( + this.organization.selectedOrganizationId() !== organizationId || + owner !== this.offline.publicationOwner() + ) + return EMPTY; return from( this.offline.saveWorkspace(intervention, workItems, changes, issues.member), ).pipe( + switchMap(() => this.equipmentCatalog.capture(intervention, owner)), + switchMap(() => + this.inventory.capture(intervention, owner).pipe(catchError(() => of(null))), + ), switchMap(() => from( workItems.filter( @@ -261,7 +320,7 @@ export class InterventionPrefetchService { ), mergeMap( (item) => - this.time.journal(item.id).pipe( + this.time.journal(item.id, 1, 30, item.allowedActions?.canManageTime !== true).pipe( switchMap((journal) => from( this.timeRepository.saveJournal( @@ -269,6 +328,14 @@ export class InterventionPrefetchService { interventionId: intervention.id, workItemId: item.id, entries: journal.entries, + audience: + item.allowedActions?.canManageTime === true ? 'all' : `member:${actorId}`, + pagination: { + page: journal.page, + itemsPerPage: journal.itemsPerPage, + totalItems: journal.totalItems, + nextPage: journal.nextPage, + }, }, owner, ), diff --git a/src/app/features/organization/features/interventions/services/intervention-prefetch/testing/intervention-prefetch.service.spec.ts b/src/app/features/organization/features/interventions/services/intervention-prefetch/testing/intervention-prefetch.service.spec.ts index a9cc2c0c2..ff4538249 100644 --- a/src/app/features/organization/features/interventions/services/intervention-prefetch/testing/intervention-prefetch.service.spec.ts +++ b/src/app/features/organization/features/interventions/services/intervention-prefetch/testing/intervention-prefetch.service.spec.ts @@ -11,6 +11,8 @@ import { InterventionTimeRepository, } from '@features/organization/features/interventions/data-access'; import { ActiveOrganizationStore } from '@features/organization/state'; +import { InterventionEquipmentCatalogService } from '../../intervention-equipment-catalog'; +import { InterventionInventoryService } from '../../intervention-inventory'; import { InterventionPrefetchService } from '../intervention-prefetch.service'; describe('InterventionPrefetchService', () => { @@ -25,12 +27,16 @@ describe('InterventionPrefetchService', () => { const offline = { publicationOwner: () => 'account', saveWorkspace: vi.fn() }; const time = { journal: vi.fn() }; const timeRepository = { saveJournal: vi.fn() }; + const catalog = { capture: vi.fn() }; + const inventory = { capture: vi.fn() }; let members: { getCurrentProfile: ReturnType }; function build(): InterventionPrefetchService { TestBed.configureTestingModule({ providers: [ InterventionPrefetchService, + { provide: InterventionEquipmentCatalogService, useValue: catalog }, + { provide: InterventionInventoryService, useValue: inventory }, { provide: AUTH_SESSION_PORT, useValue: { isAuthenticated } }, { provide: ConnectivityService, useValue: connectivity }, { provide: InterventionService, useValue: service }, @@ -52,6 +58,8 @@ describe('InterventionPrefetchService', () => { } beforeEach(() => { + catalog.capture.mockReturnValue(of(null)); + inventory.capture.mockReturnValue(of(null)); isAuthenticated.set(true); connectivity = { isOffline: vi.fn().mockReturnValue(true) }; service = { @@ -78,23 +86,55 @@ describe('InterventionPrefetchService', () => { ]), ); time.journal.mockImplementation((id: string) => - id === 'failed' ? throwError(() => new Error('Connection lost')) : of({ entries: [] }), + id === 'failed' + ? throwError(() => new Error('Connection lost')) + : of({ entries: [], page: 1, itemsPerPage: 30, totalItems: 60, nextPage: 2 }), ); build().start(); TestBed.inject(ApplicationRef).tick(); await vi.waitFor(() => expect(timeRepository.saveJournal).toHaveBeenCalledOnce()); expect(time.journal).toHaveBeenCalledTimes(2); + expect(time.journal).toHaveBeenCalledWith('authorized', 1, 30, true); + expect(time.journal).toHaveBeenCalledWith('failed', 1, 30, false); expect(time.journal).not.toHaveBeenCalledWith('forbidden'); expect(timeRepository.saveJournal).toHaveBeenCalledWith( { interventionId: 'intervention', workItemId: 'authorized', entries: [], + audience: 'member:member', + pagination: { page: 1, itemsPerPage: 30, totalItems: 60, nextPage: 2 }, }, 'account', ); expect(offline.saveWorkspace).toHaveBeenCalledOnce(); + expect(catalog.capture).toHaveBeenCalledWith( + { id: 'intervention', status: 'planned' }, + 'account', + ); }); + it.each([undefined, false, true])( + 'uses explicit beneficiary restriction when captured management is %s', + async (canManageTime) => { + connectivity.isOffline.mockReturnValue(false); + members.getCurrentProfile.mockReturnValue(of({ id: 'member' })); + service.listAll.mockReturnValue(of([{ id: 'intervention', status: 'planned' }])); + service.listAllWorkItems.mockReturnValue( + of([{ id: 'task', allowedActions: { canLogTime: true, canManageTime } }]), + ); + time.journal.mockReturnValue( + of({ entries: [], page: 1, itemsPerPage: 30, totalItems: 0, nextPage: null }), + ); + build().start(); + TestBed.inject(ApplicationRef).tick(); + await vi.waitFor(() => expect(timeRepository.saveJournal).toHaveBeenCalledOnce()); + expect(time.journal).toHaveBeenCalledExactlyOnceWith('task', 1, 30, canManageTime !== true); + expect(timeRepository.saveJournal).toHaveBeenCalledWith( + expect.objectContaining({ audience: canManageTime === true ? 'all' : 'member:member' }), + 'account', + ); + }, + ); it('should create', () => { expect(build()).toBeTruthy(); diff --git a/src/app/features/organization/features/interventions/services/intervention-replacement-context/index.ts b/src/app/features/organization/features/interventions/services/intervention-replacement-context/index.ts new file mode 100644 index 000000000..e89d08c01 --- /dev/null +++ b/src/app/features/organization/features/interventions/services/intervention-replacement-context/index.ts @@ -0,0 +1 @@ +export { InterventionReplacementContextService } from './intervention-replacement-context.service'; diff --git a/src/app/features/organization/features/interventions/services/intervention-replacement-context/intervention-replacement-context.service.ts b/src/app/features/organization/features/interventions/services/intervention-replacement-context/intervention-replacement-context.service.ts new file mode 100644 index 000000000..e67c77fc8 --- /dev/null +++ b/src/app/features/organization/features/interventions/services/intervention-replacement-context/intervention-replacement-context.service.ts @@ -0,0 +1,121 @@ +import { inject, Service } from '@angular/core'; +import { map, of, switchMap, throwError, type Observable } from 'rxjs'; +import { EquipmentService } from '@features/organization/features/equipments/data-access'; +import type { EquipmentOutput } from '@features/organization/features/equipments/models'; +import type { InterventionReplacementContext } from '@features/organization/features/interventions/models'; + +/** + * Constant EQUIPMENT_UUID + * + * @description + * Equipment identifier grammar accepted by canonical equipment resources. + * + * @type {RegExp} + */ +const EQUIPMENT_UUID: RegExp = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu; + +/** + * Service InterventionReplacementContextService + * @class InterventionReplacementContextService + * + * @description + * Confirms a published equipment replacement using scoped server reads and reciprocal identities. + */ +@Service() +export class InterventionReplacementContextService { + //#region Properties + /** + * Property equipment + * @readonly + * + * @description + * Equipment-owned transport for authorized organization-scoped reads. + * + * @access private + * @since unreleased + * + * @type {EquipmentService} + */ + private readonly equipment: EquipmentService = inject(EquipmentService); + //#endregion + + //#region Methods + /** + * Method load + * @method load + * + * @description + * Reads the original before resolving its server-declared successor. Transport failures stay + * observable so the owning query state can distinguish unavailable confirmation from absence. + * + * @access public + * @since unreleased + * + * @param {string} organizationId - Organization owning both equipment records. + * @param {string} equipmentId - Canonical original equipment UUID. + * + * @returns {Observable} Original and confirmed successor facts. + */ + public load( + organizationId: string, + equipmentId: string, + ): Observable { + if (!EQUIPMENT_UUID.test(equipmentId)) + return throwError(() => new Error('The replacement target must be an equipment UUID.')); + return this.equipment.get(organizationId, equipmentId).pipe( + switchMap((original) => { + const successorId = original.successorEquipmentId; + if ( + !this.hasCanonicalIdentity(original, organizationId, equipmentId) || + original.status !== 'decommissioned' || + !successorId || + !EQUIPMENT_UUID.test(successorId) || + successorId === original.id + ) + return of({ original, successor: null }); + return this.equipment.get(organizationId, successorId).pipe( + map((successor): InterventionReplacementContext => ({ + original, + successor: + this.hasCanonicalIdentity(successor, organizationId, successorId) && + successor.recordStatus === 'published' && + successor.predecessorEquipmentId === original.id + ? successor + : null, + })), + ); + }), + ); + } + + /** + * Method hasCanonicalIdentity + * @method hasCanonicalIdentity + * + * @description + * Verifies the equipment UUID and owning organization against a supported resource identity. + * + * @access private + * @since unreleased + * + * @param {EquipmentOutput} equipment - Server record whose identity must be verified. + * @param {string} organizationId - Requested owning organization. + * @param {string} equipmentId - Expected canonical equipment UUID. + * + * @returns {boolean} Whether the returned record matches the requested resource. + */ + private hasCanonicalIdentity( + equipment: EquipmentOutput, + organizationId: string, + equipmentId: string, + ): boolean { + return ( + equipment.id === equipmentId && + EQUIPMENT_UUID.test(equipment.id) && + equipment.organizationId === organizationId && + (equipment['@id'] === `/api/organizations/${organizationId}/equipment/${equipmentId}` || + equipment['@id'] === `/api/equipment/${equipmentId}`) + ); + } + //#endregion +} diff --git a/src/app/features/organization/features/interventions/services/intervention-replacement-context/testing/intervention-replacement-context.service.spec.ts b/src/app/features/organization/features/interventions/services/intervention-replacement-context/testing/intervention-replacement-context.service.spec.ts new file mode 100644 index 000000000..76000a86a --- /dev/null +++ b/src/app/features/organization/features/interventions/services/intervention-replacement-context/testing/intervention-replacement-context.service.spec.ts @@ -0,0 +1,142 @@ +import { TestBed } from '@angular/core/testing'; +import { firstValueFrom, of, Subject, throwError, type Observable } from 'rxjs'; +import { EquipmentService } from '@features/organization/features/equipments/data-access'; +import type { EquipmentOutput } from '@features/organization/features/equipments/models'; +import { InterventionReplacementContextService } from '../intervention-replacement-context.service'; + +describe('InterventionReplacementContextService', () => { + const organizationId = 'org-1'; + const equipmentId = '76ebd96a-c1f3-4c04-8c77-9d2b4ba9b1a4'; + const successorId = 'd1d145d0-e1c5-4e9c-a36e-1b2f267068ce'; + const unrelatedId = '5da7fd7c-30d2-4c3b-8a27-bb060a6de3e3'; + const original = { + '@id': `/api/organizations/${organizationId}/equipment/${equipmentId}`, + '@type': 'Equipment', + id: equipmentId, + organizationId, + status: 'decommissioned', + recordStatus: 'published', + successorEquipmentId: successorId, + } as EquipmentOutput; + const successor = { + '@id': `/api/organizations/${organizationId}/equipment/${successorId}`, + '@type': 'Equipment', + id: successorId, + organizationId, + status: 'operational', + recordStatus: 'published', + predecessorEquipmentId: equipmentId, + } as EquipmentOutput; + const equipment = { + get: vi.fn<(organization: string, id: string) => Observable>(), + }; + let service: InterventionReplacementContextService; + + beforeEach(() => { + equipment.get.mockReset(); + equipment.get.mockReturnValueOnce(of(original)).mockReturnValueOnce(of(successor)); + TestBed.configureTestingModule({ + providers: [ + InterventionReplacementContextService, + { provide: EquipmentService, useValue: equipment }, + ], + }); + service = TestBed.inject(InterventionReplacementContextService); + }); + + it('reads both scoped records and confirms their reciprocal published replacement', async () => { + expect(await firstValueFrom(service.load(organizationId, equipmentId))).toEqual({ + original, + successor, + }); + expect(equipment.get).toHaveBeenNthCalledWith(1, organizationId, equipmentId); + expect(equipment.get).toHaveBeenNthCalledWith(2, organizationId, successorId); + }); + + it('waits for the original record before resolving the server-declared successor', async () => { + const response = new Subject(); + equipment.get.mockReset(); + equipment.get.mockReturnValueOnce(response).mockReturnValueOnce(of(successor)); + const context = firstValueFrom(service.load(organizationId, equipmentId)); + expect(equipment.get).toHaveBeenCalledTimes(1); + response.next(original); + response.complete(); + expect((await context).successor).toEqual(successor); + expect(equipment.get).toHaveBeenCalledTimes(2); + }); + + it.each([ + { successorEquipmentId: null }, + { successorEquipmentId: undefined }, + { successorEquipmentId: '/api/equipment/arbitrary-reference' }, + { successorEquipmentId: equipmentId }, + { status: 'operational' }, + { organizationId: 'org-2' }, + { id: unrelatedId }, + { '@id': `/api/equipment/${unrelatedId}` }, + ] as const)( + 'does not confirm or fetch an unverified original relationship %j', + async (fields) => { + const unverified = { ...original, ...fields } as EquipmentOutput; + equipment.get.mockReset(); + equipment.get.mockReturnValueOnce(of(unverified)); + expect(await firstValueFrom(service.load(organizationId, equipmentId))).toEqual({ + original: unverified, + successor: null, + }); + expect(equipment.get).toHaveBeenCalledExactlyOnceWith(organizationId, equipmentId); + }, + ); + + it.each([ + { recordStatus: 'draft' }, + { recordStatus: undefined }, + { predecessorEquipmentId: null }, + { predecessorEquipmentId: unrelatedId }, + { organizationId: 'org-2' }, + { id: unrelatedId }, + { '@id': `/api/organizations/org-2/equipment/${successorId}` }, + { '@id': `/api/equipment/${unrelatedId}` }, + ] as const)('keeps an unconfirmed successor out of the result %j', async (fields) => { + equipment.get.mockReset(); + equipment.get + .mockReturnValueOnce(of(original)) + .mockReturnValueOnce(of({ ...successor, ...fields })); + expect(await firstValueFrom(service.load(organizationId, equipmentId))).toEqual({ + original, + successor: null, + }); + expect(equipment.get).toHaveBeenCalledTimes(2); + }); + + it('recognizes the canonical equipment resource identities without accepting arbitrary IRIs', async () => { + const canonicalOriginal = { ...original, '@id': `/api/equipment/${equipmentId}` }; + const canonicalSuccessor = { ...successor, '@id': `/api/equipment/${successorId}` }; + equipment.get.mockReset(); + equipment.get + .mockReturnValueOnce(of(canonicalOriginal)) + .mockReturnValueOnce(of(canonicalSuccessor)); + expect(await firstValueFrom(service.load(organizationId, equipmentId))).toEqual({ + original: canonicalOriginal, + successor: canonicalSuccessor, + }); + }); + + it('refuses a free-form target before making a resource request', async () => { + await expect( + firstValueFrom(service.load(organizationId, '/api/equipment/free-text')), + ).rejects.toThrow('The replacement target must be an equipment UUID.'); + expect(equipment.get).not.toHaveBeenCalled(); + }); + + it.each(['original', 'successor'] as const)( + 'propagates %s read failures for the owning query state', + async (failedRead) => { + const failure = { status: 403, detail: 'Equipment read unavailable.' }; + equipment.get.mockReset(); + if (failedRead === 'successor') equipment.get.mockReturnValueOnce(of(original)); + equipment.get.mockReturnValueOnce(throwError(() => failure)); + await expect(firstValueFrom(service.load(organizationId, equipmentId))).rejects.toBe(failure); + }, + ); +}); diff --git a/src/app/features/organization/features/interventions/services/intervention-sync/intervention-sync.service.ts b/src/app/features/organization/features/interventions/services/intervention-sync/intervention-sync.service.ts index 47e7cc4e5..7d7f63f07 100644 --- a/src/app/features/organization/features/interventions/services/intervention-sync/intervention-sync.service.ts +++ b/src/app/features/organization/features/interventions/services/intervention-sync/intervention-sync.service.ts @@ -11,12 +11,14 @@ import { InterventionOfflineService, InterventionService, InterventionTimeService, + InterventionInventoryRepository, } from '@features/organization/features/interventions/data-access'; import type { InterventionCollectionsChange, InterventionOutboxOperation, InterventionOutboxOperationFor, } from '@features/organization/features/interventions/models'; +import { InventoryService } from '@features/organization/features/inventory/data-access'; import { workloadAssessmentFromError } from '@features/organization/features/workload/utils'; import { ORGANIZATION_CONTEXT_PORT, @@ -31,6 +33,7 @@ import { } from './constants'; import { interventionSyncEvents } from './events'; import type { SyncProblemResponse } from './models'; +import { AcceptedInventoryPersistenceError } from './models/accepted-inventory-persistence-error'; /** * Constant DEPENDENCY_UNAVAILABLE_DETAIL @@ -267,6 +270,36 @@ export class InterventionSyncService { * @type {Map>} */ private readonly activeReplays: Map> = new Map(); + + /** + * Property inventory + * @readonly + * + * @description + * Owner-published quantitative declaration transport; valuation stays in the private cost API. + * + * @access private + * @since unreleased + * + * @type {InventoryService} + */ + private readonly inventory: InventoryService = inject(InventoryService); + + /** + * Property inventorySnapshots + * @readonly + * + * @description + * Keeps accepted server receipts durable before their idempotent queue rows are removed. + * + * @access private + * @since unreleased + * + * @type {InterventionInventoryRepository} + */ + private readonly inventorySnapshots: InterventionInventoryRepository = inject( + InterventionInventoryRepository, + ); //#endregion //#region Methods @@ -325,6 +358,9 @@ export class InterventionSyncService { const collections = new Set(); const applied = (operation: InterventionOutboxOperation): void => { switch (operation.type) { + case 'inventory-consumption.declare': + collections.add('consumptions'); + break; case 'time-entry.create': case 'time-entry.correct': case 'time-entry.cancel': @@ -442,8 +478,10 @@ export class InterventionSyncService { if (await this.skipBlockedOperation(operation, blocked)) return advance(replayed); if (!isCurrent()) return replayed; + let appliedToServer = false; try { await this.replay(organizationId, operation); + appliedToServer = true; if (!isCurrent()) return replayed; await this.offline.removeOutbox(operation.id); if (!isCurrent()) return replayed; @@ -451,6 +489,8 @@ export class InterventionSyncService { return advance(replayed + 1); } catch (error: unknown) { if (!isCurrent()) return replayed; + if (appliedToServer && operation.type === 'inventory-consumption.declare') + throw new AcceptedInventoryPersistenceError(error); const outcome = await this.handleReplayFailure(operation, error, blocked, isCurrent); if (!isCurrent()) return replayed; if (outcome === 'applied') { @@ -520,6 +560,7 @@ export class InterventionSyncService { blocked: BlockedResources, isCurrent: () => boolean, ): Promise<'applied' | 'blocked'> { + if (error instanceof AcceptedInventoryPersistenceError) throw error; const response = error as SyncProblemResponse; const detail = response.detail ?? @@ -619,7 +660,8 @@ export class InterventionSyncService { 'responsible' in operation.payload || 'participants' in operation.payload)) || (operation.type === 'work-item.update' && - ('assignee' in operation.payload || + ('executionResult' in operation.payload || + 'assignee' in operation.payload || 'remainingMinutes' in operation.payload || 'estimatedMinutes' in operation.payload || 'workStartsOn' in operation.payload || @@ -647,6 +689,37 @@ export class InterventionSyncService { operation: InterventionOutboxOperation, ): Promise { switch (operation.type) { + case 'inventory-consumption.declare': { + const isCurrent = this.captureReplayContext(); + const accountId = this.identity.profile()?.id ?? this.identity.profile()?.sub ?? null; + if (!accountId || accountId !== operation.payload.actorId) + throw new DOMException( + 'The physical declaration belongs to another account.', + 'AbortError', + ); + const input = operation.payload; + const receipt = await this.awaitReplay( + this.inventory.declareConsumption(organizationId, { + clientOperationId: input.clientOperationId, + partId: input.partId, + warehouseId: input.warehouseId, + quantity: input.quantity, + interventionId: input.interventionId, + workItemId: input.workItemId, + equipmentId: input.equipmentId, + occurredAt: input.occurredAt, + }), + ); + if (!isCurrent()) return; + if (receipt.interventionId !== operation.interventionId) + throw new Error('The server declaration does not belong to this workspace.'); + try { + await this.inventorySnapshots.saveReceipt(organizationId, accountId, receipt, isCurrent); + } catch (error: unknown) { + throw new AcceptedInventoryPersistenceError(error); + } + break; + } case 'time-entry.create': await this.awaitReplay( this.time.createEntry(operation.payload.workItemId, { @@ -1146,8 +1219,10 @@ export class InterventionSyncService { } | null> { try { if (operation.type === 'time-entry.correct' || operation.type === 'time-entry.cancel') { - const journal = await this.awaitReplay(this.time.journal(operation.payload.workItemId)); - const entry = journal.entries.find((row) => row.id === operation.payload.id); + const current = await this.awaitReplay( + this.time.getEntry(operation.payload.workItemId, operation.payload.id), + ); + const entry = current.entry; return entry ? { revision: entry.revision, @@ -1176,6 +1251,20 @@ export class InterventionSyncService { remainingMinutes: item.remainingMinutes ?? null, workStartsOn: item.workStartsOn ?? null, workEndsOn: item.workEndsOn ?? null, + ...('resultResource' in operation.payload + ? { resultResource: item.resultResource ?? null } + : {}), + ...('executionResult' in operation.payload + ? { + equipmentId: item.executionResult?.equipmentId ?? null, + performedAt: item.executionResult?.performedAt ?? null, + outcome: item.executionResult?.outcome ?? null, + workPerformed: item.executionResult?.workPerformed ?? null, + authorId: item.executionResult?.authorId ?? null, + resultState: item.executionResult?.state ?? null, + validatedAt: item.executionResult?.validatedAt ?? null, + } + : {}), }, } : null; diff --git a/src/app/features/organization/features/interventions/services/intervention-sync/models/accepted-inventory-persistence-error.ts b/src/app/features/organization/features/interventions/services/intervention-sync/models/accepted-inventory-persistence-error.ts new file mode 100644 index 000000000..860b950d0 --- /dev/null +++ b/src/app/features/organization/features/interventions/services/intervention-sync/models/accepted-inventory-persistence-error.ts @@ -0,0 +1,31 @@ +/** + * Class AcceptedInventoryPersistenceError + * @class AcceptedInventoryPersistenceError + * + * @description + * An accepted physical fact must keep its stable operation until device persistence succeeds. + */ +export class AcceptedInventoryPersistenceError extends Error { + //#region Constructor + /** + * Constructor + * @constructor + * + * @description + * Preserves the device failure that prevented retaining an already accepted server receipt. + * + * @access public + * @since unreleased + * + * @param {unknown} cause - Underlying durable storage error; replay must keep the original + * operation. + */ + public constructor(cause: unknown) { + super( + $localize`:@@intervention.inventory.receiptPersistenceFailed:The server received this declaration, but its receipt could not be saved on this device. Keep the queued declaration and synchronize again.`, + { cause }, + ); + this.name = 'AcceptedInventoryPersistenceError'; + } + //#endregion +} diff --git a/src/app/features/organization/features/interventions/services/intervention-sync/testing/intervention-sync.service.spec.ts b/src/app/features/organization/features/interventions/services/intervention-sync/testing/intervention-sync.service.spec.ts index 948daa201..200eb26bc 100644 --- a/src/app/features/organization/features/interventions/services/intervention-sync/testing/intervention-sync.service.spec.ts +++ b/src/app/features/organization/features/interventions/services/intervention-sync/testing/intervention-sync.service.spec.ts @@ -9,6 +9,7 @@ import { FacilityService } from '@features/organization/features/facilities/data import type { CreateFacilityInput } from '@features/organization/features/facilities/models'; import { InspectionService } from '@features/organization/features/inspections/data-access'; import { + InterventionInventoryRepository, InterventionOfflineService, InterventionService, InterventionTimeService, @@ -17,7 +18,13 @@ import type { InterventionOutboxOperationFor, InterventionOutboxPayloadMap, InterventionOutboxType, + InterventionWorkItemExecutionResultInput, } from '@features/organization/features/interventions/models'; +import { InventoryService } from '@features/organization/features/inventory/data-access'; +import type { + DeclareInventoryConsumptionInput, + InventoryConsumptionOutput, +} from '@features/organization/features/inventory/models'; import { ORGANIZATION_CONTEXT_PORT } from '@features/organization/ports'; import { InterventionSyncService } from '../intervention-sync.service'; @@ -36,6 +43,33 @@ function operation( } describe('InterventionSyncService', () => { + const consumption: DeclareInventoryConsumptionInput = { + clientOperationId: '11111111-1111-4111-8111-111111111111', + partId: 'part-1', + warehouseId: 'warehouse-1', + quantity: '2.000000', + interventionId: 'intervention-1', + workItemId: 'work-item-1', + equipmentId: 'equipment-1', + occurredAt: '2026-10-06T10:00:00.123Z', + }; + const receipt: InventoryConsumptionOutput = { + '@id': '/api/organizations/org-1/inventory-consumptions/receipt-1', + '@type': 'InventoryConsumption', + id: 'receipt-1', + partId: consumption.partId, + warehouseId: consumption.warehouseId, + quantity: consumption.quantity, + interventionId: consumption.interventionId, + workItemId: consumption.workItemId, + equipmentId: consumption.equipmentId, + actorId: 'account-a', + occurredAt: consumption.occurredAt, + status: 'received_pending', + reason: 'insufficient_stock', + late: false, + replayed: false, + }; let service: InterventionSyncService; let revision: WritableSignal; let profile: WritableSignal; @@ -59,6 +93,8 @@ describe('InterventionSyncService', () => { uploadEvidence: ReturnType; }; let mockInspections: { createForIntervention: ReturnType }; + let mockInventory: { declareConsumption: ReturnType }; + let mockInventorySnapshots: { saveReceipt: ReturnType }; let mockOffline: { listOutbox: ReturnType; removeOutbox: ReturnType; @@ -69,6 +105,7 @@ describe('InterventionSyncService', () => { }; beforeEach(() => { + vi.restoreAllMocks(); sessionEnded = new Subject(); revision = signal(1); profile = signal({ id: 'account-a' }); @@ -91,6 +128,8 @@ describe('InterventionSyncService', () => { uploadEvidence: vi.fn().mockReturnValue(of({})), }; mockInspections = { createForIntervention: vi.fn().mockReturnValue(of({})) }; + mockInventory = { declareConsumption: vi.fn().mockReturnValue(of(receipt)) }; + mockInventorySnapshots = { saveReceipt: vi.fn().mockResolvedValue(undefined) }; mockOffline = { listOutbox: vi.fn().mockResolvedValue([]), removeOutbox: vi.fn().mockResolvedValue(undefined), @@ -117,6 +156,7 @@ describe('InterventionSyncService', () => { provide: InterventionTimeService, useValue: { journal: vi.fn().mockReturnValue(of({ entries: [] })), + getEntry: vi.fn(), createEntry: vi.fn().mockReturnValue(of({})), correctEntry: vi.fn().mockReturnValue(of({})), cancelEntry: vi.fn().mockReturnValue(of(undefined)), @@ -126,6 +166,8 @@ describe('InterventionSyncService', () => { { provide: FacilityService, useValue: mockFacilities }, { provide: EquipmentService, useValue: mockEquipment }, { provide: InspectionService, useValue: mockInspections }, + { provide: InventoryService, useValue: mockInventory }, + { provide: InterventionInventoryRepository, useValue: mockInventorySnapshots }, { provide: InterventionOfflineService, useValue: mockOffline }, ], }); @@ -133,6 +175,173 @@ describe('InterventionSyncService', () => { service = TestBed.inject(InterventionSyncService); }); + it('persists a received pending consumption before deleting its stable outbox operation', async () => { + const operationId = `inventory:${consumption.clientOperationId}`; + mockOffline.listOutbox.mockResolvedValue([ + operation(operationId, 'inventory-consumption.declare', { + ...consumption, + actorId: 'account-a', + clientId: consumption.clientOperationId, + }), + ]); + let release: (() => void) | undefined; + mockInventorySnapshots.saveReceipt.mockImplementation( + () => + new Promise((resolve) => { + release = resolve; + }), + ); + const dispatch = vi.spyOn(TestBed.inject(Dispatcher), 'dispatch'); + const replay = service.replayOutbox('org-1', 'intervention-1'); + + await vi.waitFor(() => expect(mockInventorySnapshots.saveReceipt).toHaveBeenCalledOnce()); + expect(mockInventory.declareConsumption).toHaveBeenCalledExactlyOnceWith('org-1', consumption); + expect(mockInventorySnapshots.saveReceipt).toHaveBeenCalledExactlyOnceWith( + 'org-1', + 'account-a', + receipt, + expect.any(Function), + ); + expect(mockInventorySnapshots.saveReceipt.mock.calls[0]?.[3]()).toBe(true); + expect(mockOffline.removeOutbox).not.toHaveBeenCalled(); + expect(dispatch).not.toHaveBeenCalled(); + + release?.(); + expect(await replay).toBe(1); + expect(mockOffline.removeOutbox).toHaveBeenCalledExactlyOnceWith(operationId); + expect(dispatch).toHaveBeenCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ + interventionId: 'intervention-1', + collections: ['consumptions'], + }), + }), + ); + }); + + it('strips technical outbox actor and replay fields while retaining the original UUID and timestamp', async () => { + mockOffline.listOutbox.mockResolvedValue([ + operation('op-1', 'inventory-consumption.declare', { + ...consumption, + actorId: 'account-a', + clientId: 'technical-client-id', + }), + ]); + expect(await service.replayOutbox('org-1', 'intervention-1')).toBe(1); + expect(mockInventory.declareConsumption).toHaveBeenCalledExactlyOnceWith('org-1', consumption); + expect(mockInventory.declareConsumption.mock.calls[0]?.[1]).not.toHaveProperty('actorId'); + expect(mockInventory.declareConsumption.mock.calls[0]?.[1]).not.toHaveProperty('clientId'); + }); + + it('retains a server-accepted consumption when its durable receipt fails and replays the same UUID', async () => { + const operationId = `inventory:${consumption.clientOperationId}`; + mockOffline.listOutbox.mockResolvedValue([ + operation(operationId, 'inventory-consumption.declare', { + ...consumption, + actorId: 'account-a', + clientId: consumption.clientOperationId, + }), + ]); + mockInventorySnapshots.saveReceipt.mockRejectedValueOnce( + new DOMException('Storage full.', 'QuotaExceededError'), + ); + + await expect(service.replayOutbox('org-1', 'intervention-1')).rejects.toMatchObject({ + name: 'AcceptedInventoryPersistenceError', + cause: expect.objectContaining({ name: 'QuotaExceededError' }), + }); + expect(mockOffline.removeOutbox).not.toHaveBeenCalled(); + expect(mockOffline.markOutboxFailed).not.toHaveBeenCalled(); + expect(mockOffline.markOutboxConflict).not.toHaveBeenCalled(); + + mockInventory.declareConsumption.mockReturnValue(of({ ...receipt, replayed: true })); + expect(await service.replayOutbox('org-1', 'intervention-1')).toBe(1); + expect(mockInventory.declareConsumption.mock.calls).toEqual([ + ['org-1', consumption], + ['org-1', consumption], + ]); + expect(mockOffline.removeOutbox).toHaveBeenCalledExactlyOnceWith(operationId); + }); + + it.each(['account', 'session', 'organization'] as const)( + 'suppresses receipt persistence and queue removal after the %s changes during server replay', + async (change) => { + const pending = new Subject(); + mockInventory.declareConsumption.mockReturnValue(pending); + mockOffline.listOutbox.mockResolvedValue([ + operation('op-1', 'inventory-consumption.declare', { + ...consumption, + actorId: 'account-a', + }), + ]); + const replay = service.replayOutbox('org-1', 'intervention-1'); + await vi.waitFor(() => expect(mockInventory.declareConsumption).toHaveBeenCalledOnce()); + if (change === 'account') profile.set({ id: 'account-b' }); + if (change === 'session') revision.set(2); + if (change === 'organization') organizationId.set('org-2'); + pending.next(receipt); + pending.complete(); + + expect(await replay).toBe(0); + expect(mockInventorySnapshots.saveReceipt).not.toHaveBeenCalled(); + expect(mockOffline.removeOutbox).not.toHaveBeenCalled(); + expect(mockOffline.markOutboxFailed).not.toHaveBeenCalled(); + }, + ); + + it('does not remove a consumption when the session changes while storing its accepted receipt', async () => { + mockOffline.listOutbox.mockResolvedValue([ + operation('op-1', 'inventory-consumption.declare', { + ...consumption, + actorId: 'account-a', + }), + ]); + let release: (() => void) | undefined; + mockInventorySnapshots.saveReceipt.mockImplementation( + () => + new Promise((resolve) => { + release = resolve; + }), + ); + const replay = service.replayOutbox('org-1', 'intervention-1'); + await vi.waitFor(() => expect(mockInventorySnapshots.saveReceipt).toHaveBeenCalledOnce()); + revision.set(2); + expect(mockInventorySnapshots.saveReceipt.mock.calls[0]?.[3]()).toBe(false); + release?.(); + expect(await replay).toBe(0); + expect(mockOffline.removeOutbox).not.toHaveBeenCalled(); + }); + + it('does not submit a queued physical fact belonging to another actor', async () => { + mockOffline.listOutbox.mockResolvedValue([ + operation('op-1', 'inventory-consumption.declare', { + ...consumption, + actorId: 'account-b', + }), + ]); + await expect(service.replayOutbox('org-1', 'intervention-1')).rejects.toMatchObject({ + name: 'AbortError', + }); + expect(mockInventory.declareConsumption).not.toHaveBeenCalled(); + expect(mockInventorySnapshots.saveReceipt).not.toHaveBeenCalled(); + expect(mockOffline.removeOutbox).not.toHaveBeenCalled(); + }); + + it('retains the queued declaration when the server receipt belongs to another intervention', async () => { + mockOffline.listOutbox.mockResolvedValue([ + operation('op-1', 'inventory-consumption.declare', { + ...consumption, + actorId: 'account-a', + }), + ]); + mockInventory.declareConsumption.mockReturnValue( + of({ ...receipt, interventionId: 'intervention-2' }), + ); + expect(await service.replayOutbox('org-1', 'intervention-1')).toBe(0); + expect(mockInventorySnapshots.saveReceipt).not.toHaveBeenCalled(); + expect(mockOffline.removeOutbox).not.toHaveBeenCalled(); + }); + it.each([ { change: 'replacement', response: 'success' }, { change: 'same-account return', response: 'success' }, @@ -323,28 +532,28 @@ describe('InterventionSyncService', () => { detail: 'Time entry changed.', })), ); - vi.mocked(time.journal).mockReturnValue( + vi.mocked(time.getEntry).mockReturnValue( of({ '@id': '/api/intervention-work-items/task/time-entries', '@type': 'InterventionTimeJournal', - workItemId: 'task', - entries: [ - { - id: 'entry', - workItemId: 'task', - memberId: 'member', - workedOn: '2026-09-16', - minutes: 90, - note: 'Server note', - revision: 3, - cancelled: false, - createdBy: 'member', - updatedBy: 'manager', - createdAt: '2026-09-16T09:00:00Z', - updatedAt: '2026-09-16T10:00:00Z', - versions: [], - }, - ], + id: 'entry', + entry: { + id: 'entry', + workItemId: 'task', + memberId: 'member', + workedOn: '2026-09-16', + minutes: 90, + note: 'Server note', + revision: 3, + cancelled: false, + createdBy: 'member', + updatedBy: 'manager', + createdAt: '2026-09-16T09:00:00Z', + updatedAt: '2026-09-16T10:00:00Z', + versions: [], + totalVersions: 3, + nextBeforeRevision: 3, + }, }), ); const input = { @@ -1052,6 +1261,297 @@ describe('InterventionSyncService', () => { expect(mockOffline.removeOutbox).toHaveBeenCalledWith('op-1'); }); + it('replays the durable execution fact without changing the operator timestamp or revision', async () => { + const executionResult: InterventionWorkItemExecutionResultInput = { + equipmentId: 'equipment-1', + performedAt: '2026-10-05T16:35:00+02:00', + outcome: 'performed', + workPerformed: 'Cleaned and adjusted the pump.', + }; + const queued = operation('execution-op', 'work-item.update', { + workItemId: 'work-item-1', + clientId: 'execution-client-id', + revision: 2, + status: 'completed', + executionResult, + }); + mockOffline.listOutbox.mockResolvedValue([queued]); + + expect(await service.replayOutbox('org-1', 'intervention-1')).toBe(1); + expect(mockInterventionService.updateWorkItem).toHaveBeenCalledWith( + 'work-item-1', + { status: 'completed', executionResult }, + 2, + ); + expect(mockOffline.removeOutbox).toHaveBeenCalledWith('execution-op'); + expect(queued.payload.executionResult?.performedAt).toBe('2026-10-05T16:35:00+02:00'); + expect(queued.payload.revision).toBe(2); + }); + + it('keeps the local result and verified server facts for human review after a revision conflict', async () => { + const executionResult: InterventionWorkItemExecutionResultInput = { + equipmentId: 'equipment-1', + performedAt: '2026-10-05T16:35:00+02:00', + outcome: 'failed', + workPerformed: 'Repair attempt failed; the valve must be replaced.', + }; + const queued = operation('execution-op', 'work-item.update', { + workItemId: 'work-item-1', + revision: 2, + status: 'in_progress', + executionResult, + }); + mockOffline.listOutbox.mockResolvedValue([queued]); + mockInterventionService.updateWorkItem.mockReturnValue( + throwError(() => ({ status: 412, detail: 'Another result was recorded.' })), + ); + mockInterventionService.listAllWorkItems.mockReturnValue( + of([ + { + id: 'work-item-1', + revision: 5, + status: 'completed', + executionResult: { + equipmentId: 'equipment-1', + performedAt: '2026-10-06T10:00:00Z', + outcome: 'successful', + workPerformed: 'Installed a new valve.', + authorId: 'member-2', + state: 'staged', + validatedAt: null, + }, + }, + ]), + ); + + expect(await service.replayOutbox('org-1', 'intervention-1')).toBe(0); + expect(mockOffline.markOutboxConflict).toHaveBeenCalledWith( + 'execution-op', + 'Another result was recorded.', + null, + { + revision: 5, + values: { + assignee: null, + status: 'completed', + estimatedMinutes: null, + remainingMinutes: null, + workStartsOn: null, + workEndsOn: null, + equipmentId: 'equipment-1', + performedAt: '2026-10-06T10:00:00Z', + outcome: 'successful', + workPerformed: 'Installed a new valve.', + authorId: 'member-2', + resultState: 'staged', + validatedAt: null, + }, + }, + ); + expect(mockOffline.rebaseOutboxRevision).not.toHaveBeenCalled(); + expect(mockOffline.removeOutbox).not.toHaveBeenCalled(); + expect(queued.payload.executionResult).toEqual(executionResult); + expect(queued.payload.revision).toBe(2); + }); + + it('replays replacement completion, successor and execution facts in one revision-guarded command', async () => { + const resultResource = '/api/equipment/d1d145d0-e1c5-4e9c-a36e-1b2f267068ce'; + const executionResult: InterventionWorkItemExecutionResultInput = { + equipmentId: '76ebd96a-c1f3-4c04-8c77-9d2b4ba9b1a4', + performedAt: '2026-10-06T09:35:00+02:00', + outcome: 'successful', + workPerformed: 'Replaced the decommissioned extinguisher with its published successor.', + }; + const queued = operation('replacement-op', 'work-item.update', { + workItemId: 'replacement-work-item', + clientId: 'replacement-client-id', + revision: 7, + status: 'completed', + resultResource, + executionResult, + }); + mockOffline.listOutbox.mockResolvedValue([queued]); + + expect(await service.replayOutbox('org-1', 'intervention-1')).toBe(1); + expect(mockInterventionService.updateWorkItem).toHaveBeenCalledExactlyOnceWith( + 'replacement-work-item', + { status: 'completed', resultResource, executionResult }, + 7, + ); + expect(mockOffline.removeOutbox).toHaveBeenCalledExactlyOnceWith('replacement-op'); + expect(queued.payload).toEqual({ + workItemId: 'replacement-work-item', + clientId: 'replacement-client-id', + revision: 7, + status: 'completed', + resultResource, + executionResult, + }); + }); + + it('retains the queued successor and operator facts when a replacement result revision conflicts', async () => { + const resultResource = '/api/equipment/d1d145d0-e1c5-4e9c-a36e-1b2f267068ce'; + const serverResource = '/api/equipment/5da7fd7c-30d2-4c3b-8a27-bb060a6de3e3'; + const executionResult: InterventionWorkItemExecutionResultInput = { + equipmentId: '76ebd96a-c1f3-4c04-8c77-9d2b4ba9b1a4', + performedAt: '2026-10-06T09:35:00+02:00', + outcome: 'successful', + workPerformed: 'Installed the verified replacement extinguisher.', + }; + const queued = operation('replacement-op', 'work-item.update', { + workItemId: 'replacement-work-item', + revision: 7, + status: 'completed', + resultResource, + executionResult, + }); + mockOffline.listOutbox.mockResolvedValue([queued]); + mockInterventionService.updateWorkItem.mockReturnValueOnce( + throwError(() => ({ status: 412, detail: 'The replacement result changed.' })), + ); + mockInterventionService.listAllWorkItems.mockReturnValueOnce( + of([ + { + id: 'replacement-work-item', + revision: 9, + status: 'completed', + resultResource: serverResource, + executionResult: { + ...executionResult, + performedAt: '2026-10-06T12:00:00Z', + workPerformed: 'Recorded a different successor.', + authorId: 'another-member', + state: 'staged', + validatedAt: null, + }, + }, + ]), + ); + + expect(await service.replayOutbox('org-1', 'intervention-1')).toBe(0); + expect(mockInterventionService.updateWorkItem).toHaveBeenCalledExactlyOnceWith( + 'replacement-work-item', + { status: 'completed', resultResource, executionResult }, + 7, + ); + expect(mockOffline.markOutboxConflict).toHaveBeenCalledExactlyOnceWith( + 'replacement-op', + 'The replacement result changed.', + null, + { + revision: 9, + values: { + assignee: null, + status: 'completed', + estimatedMinutes: null, + remainingMinutes: null, + workStartsOn: null, + workEndsOn: null, + resultResource: serverResource, + equipmentId: executionResult.equipmentId, + performedAt: '2026-10-06T12:00:00Z', + outcome: 'successful', + workPerformed: 'Recorded a different successor.', + authorId: 'another-member', + resultState: 'staged', + validatedAt: null, + }, + }, + ); + expect(queued.payload).toEqual({ + workItemId: 'replacement-work-item', + revision: 7, + status: 'completed', + resultResource, + executionResult, + }); + expect(mockOffline.rebaseOutboxRevision).not.toHaveBeenCalled(); + expect(mockOffline.removeOutbox).not.toHaveBeenCalled(); + }); + + it('preserves an execution conflict when the current server fact cannot be fetched', async () => { + mockOffline.listOutbox.mockResolvedValue([ + operation('clear-result', 'work-item.update', { + workItemId: 'work-item-1', + executionResult: null, + revision: 2, + }), + ]); + mockInterventionService.updateWorkItem.mockReturnValue( + throwError(() => ({ status: 412, detail: 'The work item changed.' })), + ); + mockInterventionService.listAllWorkItems.mockReturnValue(throwError(() => ({ status: 503 }))); + + expect(await service.replayOutbox('org-1', 'intervention-1')).toBe(0); + expect(mockOffline.markOutboxConflict).toHaveBeenCalledWith( + 'clear-result', + 'The work item changed.', + null, + null, + ); + expect(mockOffline.rebaseOutboxRevision).not.toHaveBeenCalled(); + expect(mockOffline.removeOutbox).not.toHaveBeenCalled(); + }); + + it('retries a result after a transient server failure without losing or duplicating local intent', async () => { + const queued = operation('execution-op', 'work-item.update', { + workItemId: 'work-item-1', + revision: 2, + status: 'completed', + executionResult: { + equipmentId: 'equipment-1', + performedAt: '2026-10-05T16:35:00+02:00', + outcome: 'successful', + workPerformed: 'Replaced the valve.', + }, + }); + mockOffline.listOutbox.mockResolvedValue([queued]); + mockInterventionService.updateWorkItem.mockReturnValueOnce( + throwError(() => ({ status: 503, detail: 'Service temporarily unavailable.' })), + ); + + expect(await service.replayOutbox('org-1', 'intervention-1')).toBe(0); + expect(mockOffline.removeOutbox).not.toHaveBeenCalled(); + expect(mockOffline.markOutboxConflict).not.toHaveBeenCalled(); + expect(mockOffline.markOutboxFailed).not.toHaveBeenCalled(); + expect(await service.replayOutbox('org-1', 'intervention-1')).toBe(1); + expect(mockInterventionService.updateWorkItem).toHaveBeenNthCalledWith( + 2, + 'work-item-1', + { status: 'completed', executionResult: queued.payload.executionResult }, + 2, + ); + expect(mockOffline.removeOutbox).toHaveBeenCalledTimes(1); + }); + + it('ignores a late execution acknowledgement after the recording account changes', async () => { + const held = new Subject>(); + mockOffline.listOutbox.mockResolvedValue([ + operation('execution-op', 'work-item.update', { + workItemId: 'work-item-1', + revision: 2, + executionResult: { + equipmentId: 'equipment-1', + performedAt: '2026-10-05T16:35:00+02:00', + outcome: 'performed', + workPerformed: 'Cleaned and adjusted the pump.', + }, + }), + ]); + mockInterventionService.updateWorkItem.mockReturnValueOnce(held); + const replay = service.replayOutbox('org-1', 'intervention-1'); + await vi.waitFor(() => expect(mockInterventionService.updateWorkItem).toHaveBeenCalledOnce()); + profile.set({ id: 'account-b' }); + revision.set(2); + sessionEnded.next(); + held.next({}); + + expect(await replay).toBe(0); + expect(mockOffline.removeOutbox).not.toHaveBeenCalled(); + expect(mockOffline.markOutboxConflict).not.toHaveBeenCalled(); + expect(mockOffline.rebaseOutboxRevision).not.toHaveBeenCalled(); + }); + it('should replay a queued intervention change creation', async () => { mockOffline.listOutbox.mockResolvedValue([ operation('op-1', 'change.create', { diff --git a/src/app/features/organization/features/interventions/services/intervention-time-journal/intervention-time-journal.service.ts b/src/app/features/organization/features/interventions/services/intervention-time-journal/intervention-time-journal.service.ts index a5f4b99ab..0a04483ab 100644 --- a/src/app/features/organization/features/interventions/services/intervention-time-journal/intervention-time-journal.service.ts +++ b/src/app/features/organization/features/interventions/services/intervention-time-journal/intervention-time-journal.service.ts @@ -20,6 +20,8 @@ import type { InterventionTimeScope, InterventionTimeWrite, InterventionTimeJournalView, + InterventionTimeEntryVersionsOutput, + InterventionTimeEntryOutput, } from '@features/organization/features/interventions/models'; import { projectInterventionTime } from '@features/organization/features/interventions/utils'; @@ -102,20 +104,27 @@ export class InterventionTimeJournalService { * @since 1.0.0 * * @param {InterventionTimeScope} scope - Captured task/account scope. - * @returns {Observable} Journal with draft and synchronization status. + * @param {number} page - Positive requested journal page. + * + * @returns {Observable} Journal with draft and synchronization + * status. */ - public read(scope: InterventionTimeScope): Observable { + public read(scope: InterventionTimeScope, page = 1): Observable { return defer(() => { const owner = this.offline.publicationOwner(); const cached = () => - from(this.repository.readJournal(scope.interventionId, scope.workItemId)).pipe( - map((entries) => ({ - entries: entries ?? [], + from(this.repository.readJournalPage(scope, page)).pipe( + map((snapshot) => ({ + entries: snapshot?.entries ?? [], + page, + itemsPerPage: snapshot?.itemsPerPage ?? 30, + totalItems: snapshot?.totalItems ?? null, + nextPage: snapshot?.nextPage ?? null, offline: true, - historyUnavailable: entries === null, + historyUnavailable: snapshot === null, })), ); - const remote = this.api.journal(scope.workItemId).pipe( + const remote = this.api.journal(scope.workItemId, page, 30, scope.manageOthers !== true).pipe( switchMap((journal) => from( this.repository.saveJournal( @@ -123,11 +132,26 @@ export class InterventionTimeJournalService { interventionId: scope.interventionId, workItemId: scope.workItemId, entries: journal.entries, + audience: scope.manageOthers === true ? 'all' : `member:${scope.actorId}`, + pagination: { + page: journal.page, + itemsPerPage: journal.itemsPerPage, + totalItems: journal.totalItems, + nextPage: journal.nextPage, + }, }, owner, ), ).pipe( - map(() => ({ entries: journal.entries, offline: false, historyUnavailable: false })), + map(() => ({ + entries: journal.entries, + page: journal.page, + itemsPerPage: journal.itemsPerPage, + totalItems: journal.totalItems, + nextPage: journal.nextPage, + offline: false, + historyUnavailable: false, + })), ), ), catchError((error: unknown) => @@ -142,16 +166,120 @@ export class InterventionTimeJournalService { map(({ journal, draft, operations }) => { if (owner !== this.offline.publicationOwner()) throw new Error('The active account changed.'); + const visibleIds = new Set(journal.entries.map((entry) => entry.id)); + if (page === 1) + for (const operation of operations) + if ( + operation.type === 'time-entry.create' && + operation.payload.workItemId === scope.workItemId && + (scope.manageOthers || operation.payload.memberId === scope.actorId) + ) + visibleIds.add(operation.payload.id); return { ...journal, draft, - entries: projectInterventionTime(journal.entries, operations, scope.workItemId), + entries: projectInterventionTime( + journal.entries, + operations.filter((operation) => { + if ( + (operation.type === 'time-entry.create' || + operation.type === 'time-entry.correct') && + !scope.manageOthers && + operation.payload.memberId !== scope.actorId + ) + return false; + return ( + !operation.type.startsWith('time-entry.') || + ('id' in operation.payload && visibleIds.has(operation.payload.id)) + ); + }), + scope.workItemId, + ), }; }), ); }); } + /** + * Method readVersions + * @method readVersions + * + * @description + * Reads one explicit history page online; device snapshots never imply complete audit history. + * + * @access public + * @since unreleased + * + * @param {InterventionTimeScope} scope - Captured task/account authority. + * @param {string} entryId - Authorized entry whose history was expanded. + * @param {number} beforeRevision - Exclusive cursor for older revisions. + * + * @returns {Observable} One authorized immutable history + * page. + */ + public readVersions( + scope: InterventionTimeScope, + entryId: string, + beforeRevision?: number, + ): Observable { + return defer(() => { + if (this.connectivity.isOffline()) + return throwError( + () => + new Error( + $localize`:@@intervention.time.historyOffline:Correction history is unavailable offline. Reconnect to load earlier versions.`, + ), + ); + const owner = this.offline.publicationOwner(); + return this.api.versions(scope.workItemId, entryId, beforeRevision).pipe( + map((result) => { + if (owner !== this.offline.publicationOwner()) + throw new Error('The active account changed.'); + return result; + }), + ); + }); + } + + /** + * Method readEntry + * @method readEntry + * + * @description + * Reads the current saved correction target directly when it is outside the displayed page. + * + * @access public + * @since unreleased + * + * @param {InterventionTimeScope} scope - Captured task/account authority. + * @param {string} entryId - Saved correction target to review. + * + * @returns {Observable} Bounded current entry for revision review. + */ + public readEntry( + scope: InterventionTimeScope, + entryId: string, + ): Observable { + return defer(() => { + if (this.connectivity.isOffline()) + return throwError( + () => + new Error( + $localize`:@@intervention.time.reviewOffline:Reconnect to review the current version of this saved correction.`, + ), + ); + const owner = this.offline.publicationOwner(); + return this.api.getEntry(scope.workItemId, entryId).pipe( + map((result) => { + if (owner !== this.offline.publicationOwner()) + throw new Error('The active account changed.'); + return result; + }), + ); + }); + } + /** * Method write * @method write @@ -165,6 +293,7 @@ export class InterventionTimeJournalService { * @param {InterventionTimeScope} scope - Task and actor captured on submission. * @param {InterventionTimeWrite} command - Stable entry identifier and reviewed revision. * @param {string | null} expectedOwner - Account captured before queued draft persistence. + * * @returns {Observable<'queued' | 'remote'>} Where the intention was durably recorded. */ public write( diff --git a/src/app/features/organization/features/interventions/services/intervention-time-journal/testing/intervention-time-journal.service.spec.ts b/src/app/features/organization/features/interventions/services/intervention-time-journal/testing/intervention-time-journal.service.spec.ts index 2d331cac4..28c30f15d 100644 --- a/src/app/features/organization/features/interventions/services/intervention-time-journal/testing/intervention-time-journal.service.spec.ts +++ b/src/app/features/organization/features/interventions/services/intervention-time-journal/testing/intervention-time-journal.service.spec.ts @@ -6,6 +6,7 @@ import { InterventionOfflineService, InterventionTimeService, } from '@features/organization/features/interventions/data-access'; +import type { InterventionTimeRecord } from '@features/organization/features/interventions/data-access/services/intervention-offline/models/intervention-time-record.interface'; import { InterventionTimeJournalService } from '../intervention-time-journal.service'; describe('InterventionTimeJournalService', () => { const scope = { workItemId: 'task', interventionId: 'intervention', actorId: 'member' }; @@ -21,12 +22,14 @@ describe('InterventionTimeJournalService', () => { let service: InterventionTimeJournalService; const api = { journal: vi.fn(), + versions: vi.fn(), + getEntry: vi.fn(), createEntry: vi.fn(), correctEntry: vi.fn(), cancelEntry: vi.fn(), }; const cache = { - readJournal: vi.fn(), + readJournalPage: vi.fn(), readDraft: vi.fn(), saveJournal: vi.fn(), clearDraft: vi.fn(), @@ -35,11 +38,13 @@ describe('InterventionTimeJournalService', () => { beforeEach(() => { owner = 'account'; offlineMode = false; - api.journal.mockReturnValue(of({ entries: [] })); + api.journal.mockReturnValue( + of({ entries: [], page: 1, itemsPerPage: 30, totalItems: 0, nextPage: null }), + ); api.createEntry.mockReturnValue(of({})); api.correctEntry.mockReturnValue(of({})); api.cancelEntry.mockReturnValue(of(undefined)); - cache.readJournal.mockResolvedValue(null); + cache.readJournalPage.mockResolvedValue(null); cache.readDraft.mockResolvedValue(null); cache.saveJournal.mockResolvedValue(undefined); cache.clearDraft.mockResolvedValue(undefined); @@ -67,6 +72,166 @@ describe('InterventionTimeJournalService', () => { service = TestBed.inject(InterventionTimeJournalService); }); afterEach(() => vi.clearAllMocks()); + it('reads and caches one bounded requested page without starting history requests', async () => { + api.journal.mockReturnValueOnce( + of({ entries: [], page: 2, itemsPerPage: 30, totalItems: 75, nextPage: 3 }), + ); + expect(await firstValueFrom(service.read(scope, 2))).toMatchObject({ + page: 2, + itemsPerPage: 30, + totalItems: 75, + nextPage: 3, + offline: false, + }); + expect(api.journal).toHaveBeenCalledExactlyOnceWith('task', 2, 30, true); + expect(api.versions).not.toHaveBeenCalled(); + expect(cache.saveJournal).toHaveBeenCalledWith( + { + interventionId: 'intervention', + workItemId: 'task', + entries: [], + audience: 'member:member', + pagination: { page: 2, itemsPerPage: 30, totalItems: 75, nextPage: 3 }, + }, + 'account', + ); + }); + it('keeps a captured restricted page beneficiary-only across a server grant and an offline revocation', async () => { + const own = { + id: 'own', + workItemId: 'task', + memberId: 'member', + workedOn: '2026-09-16', + minutes: 60, + revision: 1, + cancelled: false, + createdBy: 'member', + updatedBy: 'member', + createdAt: '2026-09-16T12:00:00Z', + updatedAt: '2026-09-16T12:00:00Z', + versions: [], + }; + const foreign = { ...own, id: 'foreign', memberId: 'other-member' }; + let serverCanManage = true; + api.journal.mockImplementation( + (_task: string, page: number, size: number, ownOnly: boolean) => { + const entries = ownOnly || !serverCanManage ? [own] : [own, foreign]; + return of({ + entries, + page, + itemsPerPage: size, + totalItems: entries.length, + nextPage: null, + }); + }, + ); + cache.saveJournal.mockImplementation(async (record: InterventionTimeRecord) => { + cache.readJournalPage.mockResolvedValue({ entries: record.entries, ...record.pagination }); + }); + const captured = { ...scope, manageOthers: false }; + const online = await firstValueFrom(service.read(captured)); + expect(api.journal).toHaveBeenCalledExactlyOnceWith('task', 1, 30, true); + expect(online.entries.map((entry) => entry.memberId)).toEqual(['member']); + expect(online.totalItems).toBe(1); + serverCanManage = false; + offlineMode = true; + const cached = await firstValueFrom(service.read(captured)); + expect(cached).toMatchObject({ offline: true, totalItems: 1, historyUnavailable: false }); + expect(cached.entries.map((entry) => entry.memberId)).toEqual(['member']); + expect(cache.saveJournal).toHaveBeenCalledOnce(); + }); + it('requests a broad journal only for an explicitly captured management grant', async () => { + await firstValueFrom(service.read({ ...scope, manageOthers: true })); + expect(api.journal).toHaveBeenCalledExactlyOnceWith('task', 1, 30, false); + }); + it('uses only an exact cached offline page and preserves unknown total on a missing page', async () => { + offlineMode = true; + cache.readJournalPage.mockResolvedValueOnce({ + entries: [], + page: 2, + itemsPerPage: 30, + totalItems: 31, + nextPage: null, + }); + expect(await firstValueFrom(service.read(scope, 2))).toMatchObject({ + page: 2, + totalItems: 31, + offline: true, + historyUnavailable: false, + }); + expect(cache.readJournalPage).toHaveBeenLastCalledWith(scope, 2); + expect(await firstValueFrom(service.read(scope, 3))).toMatchObject({ + page: 3, + totalItems: null, + offline: true, + historyUnavailable: true, + }); + }); + it('projects new local entries only on page one while preserving exact saved totals', async () => { + offline.listOutbox.mockResolvedValue([ + { + id: 'operation', + interventionId: 'intervention', + type: 'time-entry.create', + payload: { ...input, actorId: 'member', workItemId: 'task' }, + createdAt: '2026-09-16T12:00:00Z', + }, + ]); + api.journal + .mockReturnValueOnce( + of({ entries: [], page: 1, itemsPerPage: 30, totalItems: 31, nextPage: 2 }), + ) + .mockReturnValueOnce( + of({ entries: [], page: 2, itemsPerPage: 30, totalItems: 31, nextPage: null }), + ); + const first = await firstValueFrom(service.read(scope)); + expect(first.entries.map((entry) => entry.id)).toEqual(['stable-id']); + expect(first.totalItems).toBe(31); + const second = await firstValueFrom(service.read(scope, 2)); + expect(second.entries).toEqual([]); + expect(second.totalItems).toBe(31); + }); + it('keeps historical revisions explicitly unavailable offline and never substitutes device data', async () => { + offlineMode = true; + await expect(firstValueFrom(service.readVersions(scope, 'entry'))).rejects.toThrow( + 'unavailable offline', + ); + expect(api.versions).not.toHaveBeenCalled(); + expect(cache.readJournalPage).not.toHaveBeenCalled(); + offlineMode = false; + api.versions.mockReturnValueOnce(throwError(() => ({ status: 0 }))); + await expect(firstValueFrom(service.readVersions(scope, 'entry', 3))).rejects.toMatchObject({ + status: 0, + }); + expect(api.versions).toHaveBeenLastCalledWith('task', 'entry', 3); + expect(cache.readJournalPage).not.toHaveBeenCalled(); + }); + it('does not project another beneficiary queued entry into a restricted journal', async () => { + offline.listOutbox.mockResolvedValue([ + { + id: 'operation', + interventionId: 'intervention', + type: 'time-entry.create', + payload: { ...input, memberId: 'other-member', actorId: 'member', workItemId: 'task' }, + createdAt: '2026-09-16T12:00:00Z', + }, + ]); + expect((await firstValueFrom(service.read({ ...scope, manageOthers: false }))).entries).toEqual( + [], + ); + expect( + (await firstValueFrom(service.read({ ...scope, manageOthers: true }))).entries.map( + (entry) => entry.memberId, + ), + ).toEqual(['other-member']); + }); + it('reads an off-page saved correction target directly without scanning journal or revision pages', async () => { + api.getEntry.mockReturnValueOnce(of({ id: 'entry', entry: {} })); + await firstValueFrom(service.readEntry(scope, 'entry')); + expect(api.getEntry).toHaveBeenCalledExactlyOnceWith('task', 'entry'); + expect(api.journal).not.toHaveBeenCalled(); + expect(api.versions).not.toHaveBeenCalled(); + }); it('queues a network-failed create with the same client ID', async () => { api.createEntry.mockReturnValueOnce(throwError(() => ({ status: 0 }))); expect(await firstValueFrom(service.write(scope, { kind: 'create', input }, owner))).toBe( diff --git a/src/app/features/organization/features/interventions/state/intervention-inventory/index.ts b/src/app/features/organization/features/interventions/state/intervention-inventory/index.ts new file mode 100644 index 000000000..2482c851e --- /dev/null +++ b/src/app/features/organization/features/interventions/state/intervention-inventory/index.ts @@ -0,0 +1,4 @@ +export { + InterventionInventoryStore, + type InterventionInventoryStoreType, +} from './intervention-inventory.store'; diff --git a/src/app/features/organization/features/interventions/state/intervention-inventory/intervention-inventory.store.ts b/src/app/features/organization/features/interventions/state/intervention-inventory/intervention-inventory.store.ts new file mode 100644 index 000000000..4e0d362d7 --- /dev/null +++ b/src/app/features/organization/features/interventions/state/intervention-inventory/intervention-inventory.store.ts @@ -0,0 +1,316 @@ +import { isPlatformBrowser } from '@angular/common'; +import { computed, DOCUMENT, effect, inject, PLATFORM_ID } from '@angular/core'; +import { tapResponse } from '@ngrx/operators'; +import { + patchState, + signalStore, + withComputed, + withHooks, + withMethods, + withState, +} from '@ngrx/signals'; +import { removeAllEntities, setAllEntities, withEntities } from '@ngrx/signals/entities'; +import { rxMethod } from '@ngrx/signals/rxjs-interop'; +import { concatMap, defer, EMPTY, from, pipe, switchMap } from 'rxjs'; +import { ConnectivityService } from '@core/connectivity'; +import { + errorCallState, + idleCallState, + pendingCallState, + successCallState, + toStoreError, +} from '@core/request-state'; +import { InterventionOfflineService } from '@features/organization/features/interventions/data-access'; +import type { InterventionInventoryScope } from '@features/organization/features/interventions/models'; +import { InterventionInventoryService } from '@features/organization/features/interventions/services/intervention-inventory'; +import type { + DeclareInventoryConsumptionInput, + InventoryConsumptionIntent, + InventoryConsumptionOutput, +} from '@features/organization/features/inventory/models'; +import type { InterventionInventoryState } from './models/intervention-inventory-state.interface'; + +/** + * Constant INITIAL_STATE + * + * @description + * No reference is usable offline until a complete authorized snapshot is durable. + * + * @type {InterventionInventoryState} + */ +const INITIAL_STATE: InterventionInventoryState = { + scope: null, + snapshot: null, + readCallState: idleCallState(), + queueCallState: idleCallState(), + localIntents: [], + acceptedOperationId: null, + unpersistedInput: null, + fromDevice: false, +}; + +/** + * Function sameScope + * + * @description + * Compares captured account, session and workspace identity. + * + * @param {InterventionInventoryScope | null} left - Previous captured scope, or none before + * activation. + * @param {InterventionInventoryScope} right - Scope whose account, session and workspace must + * match. + * + * @returns {boolean} Whether both captures still describe the same authorized context. + */ +function sameScope( + left: InterventionInventoryScope | null, + right: InterventionInventoryScope, +): boolean { + return ( + left?.accountId === right.accountId && + left.sessionRevision === right.sessionRevision && + left.organizationId === right.organizationId && + left.interventionId === right.interventionId + ); +} + +/** + * Constant InterventionInventoryStore + * + * @description + * Intervention-scoped parts declarations: a device receipt clears input only after durable storage. + * A server receipt can remain received_pending; it is distinct from transport synchronization. + */ +export const InterventionInventoryStore = signalStore( + withState(INITIAL_STATE), + withEntities(), + withComputed((store) => ({ + /** + * @description + * Whether leaving would lose a submitted physical fact. + */ + hasUnpersistedDeclaration: computed(() => store.unpersistedInput() !== null), + /** + * @description + * Complete history and catalogs are not inferred from an individual receipt. + */ + catalogReady: computed(() => store.snapshot()?.catalogComplete === true), + /** + * @description + * Canonical server acknowledgments that still require a stock reconciliation. + */ + unresolvedCount: computed( + () => store.entities().filter((item) => item.status === 'received_pending').length, + ), + })), + withMethods( + ( + store, + inventory = inject(InterventionInventoryService), + offline = inject(InterventionOfflineService), + ) => { + const current = (scope: InterventionInventoryScope): boolean => + sameScope(store.scope(), scope) && inventory.isCurrent(scope); + const readIntents = async ( + scope: InterventionInventoryScope, + ): Promise => { + if (!current(scope)) return []; + const operations = await offline.listOutbox(scope.interventionId); + if (!current(scope)) return []; + return operations.flatMap((operation): readonly InventoryConsumptionIntent[] => { + if ( + operation.type !== 'inventory-consumption.declare' || + operation.payload.actorId !== scope.accountId + ) + return []; + const input: DeclareInventoryConsumptionInput = { + clientOperationId: operation.payload.clientOperationId, + interventionId: operation.interventionId, + partId: operation.payload.partId, + warehouseId: operation.payload.warehouseId, + quantity: operation.payload.quantity, + workItemId: operation.payload.workItemId ?? null, + equipmentId: operation.payload.equipmentId ?? null, + occurredAt: operation.payload.occurredAt, + }; + return [ + { + input, + status: operation.status === 'pending' ? 'queued' : 'failed', + error: operation.error, + }, + ]; + }); + }; + const refreshLocal = rxMethod( + pipe( + switchMap((scope) => + current(scope) + ? from(readIntents(scope)).pipe( + tapResponse({ + next: (localIntents) => { + if (current(scope)) patchState(store, { localIntents }); + }, + error: (error: unknown) => { + if (current(scope)) + patchState(store, { readCallState: errorCallState(toStoreError(error)) }); + }, + }), + ) + : EMPTY, + ), + ), + ); + return { + /** + * @description + * Clears obsolete facts and restores durable local declarations independently of network. + */ + refreshLocal, + /** + * @description + * Serializes stable operations. Repeating a failed declaration retries the same durable + * row. Input is acknowledged after the database confirms its transaction, even if a later + * read fails. + */ + declare: rxMethod<{ + readonly scope: InterventionInventoryScope; + readonly input: DeclareInventoryConsumptionInput; + }>( + pipe( + concatMap(({ scope, input }) => { + if ( + !current(scope) || + !inventory.isCurrent(scope, true) || + input.interventionId !== scope.interventionId + ) + return EMPTY; + const snapshot = store.snapshot(); + if ( + !snapshot?.catalogComplete || + !snapshot.parts.some((part) => part.id === input.partId && !part.archived) || + !snapshot.warehouses.some( + (warehouse) => warehouse.id === input.warehouseId && !warehouse.archived, + ) + ) + return EMPTY; + patchState(store, { + queueCallState: pendingCallState(), + acceptedOperationId: null, + unpersistedInput: input, + }); + return from( + offline.queueInventoryConsumption( + scope.organizationId, + input, + scope.accountId, + () => current(scope) && inventory.isCurrent(scope, true), + ), + ).pipe( + tapResponse({ + next: () => { + if (!current(scope)) return; + patchState(store, { + queueCallState: successCallState(null), + acceptedOperationId: input.clientOperationId, + unpersistedInput: null, + }); + refreshLocal(scope); + }, + error: (error: unknown) => { + if (current(scope)) + patchState(store, { queueCallState: errorCallState(toStoreError(error)) }); + }, + }), + ); + }), + ), + ), + }; + }, + ), + withMethods( + ( + store, + inventory = inject(InterventionInventoryService), + connectivity = inject(ConnectivityService), + ) => ({ + /** + * @description + * Reads every authorized server page, retaining a previous complete snapshot if disconnected. + */ + load: rxMethod( + pipe( + switchMap((scope) => { + if (!scope || !inventory.isCurrent(scope)) { + patchState(store, INITIAL_STATE, removeAllEntities()); + return EMPTY; + } + if (!sameScope(store.scope(), scope)) + patchState(store, INITIAL_STATE, removeAllEntities()); + patchState(store, { scope, readCallState: pendingCallState() }); + const current = (): boolean => + sameScope(store.scope(), scope) && inventory.isCurrent(scope); + return defer(async () => { + const saved = await inventory.loadSaved(scope); + if (!current()) return null; + if (saved) + patchState( + store, + { snapshot: saved, fromDevice: true }, + setAllEntities([...saved.declarations]), + ); + store.refreshLocal(scope); + if (connectivity.isOffline()) return saved; + const fresh = await inventory.refresh(scope); + return current() ? fresh : null; + }).pipe( + tapResponse({ + next: (snapshot) => { + if (!current()) return; + patchState( + store, + { + snapshot, + fromDevice: connectivity.isOffline(), + readCallState: successCallState(null), + }, + setAllEntities([...(snapshot?.declarations ?? [])]), + ); + }, + error: (error: unknown) => { + if (current()) + patchState(store, { readCallState: errorCallState(toStoreError(error)) }); + }, + }), + ); + }), + ), + ), + }), + ), + withHooks((store, document = inject(DOCUMENT), platform = inject(PLATFORM_ID)) => ({ + onInit(): void { + if (!isPlatformBrowser(platform) || !document.defaultView) return; + const view = document.defaultView; + effect((onCleanup) => { + if (!store.hasUnpersistedDeclaration()) return; + const beforeUnload = (event: BeforeUnloadEvent): void => { + if (store.hasUnpersistedDeclaration()) event.preventDefault(); + }; + view.addEventListener('beforeunload', beforeUnload); + onCleanup(() => view.removeEventListener('beforeunload', beforeUnload)); + }); + }, + })), +); + +/** + * Type InterventionInventoryStoreType + * + * @description + * Component-scoped device and server stock state. + * + * @type {InstanceType} + */ +export type InterventionInventoryStoreType = InstanceType; diff --git a/src/app/features/organization/features/interventions/state/intervention-inventory/models/intervention-inventory-state.interface.ts b/src/app/features/organization/features/interventions/state/intervention-inventory/models/intervention-inventory-state.interface.ts new file mode 100644 index 000000000..76933f478 --- /dev/null +++ b/src/app/features/organization/features/interventions/state/intervention-inventory/models/intervention-inventory-state.interface.ts @@ -0,0 +1,106 @@ +import type { CallState } from '@core/request-state'; +import type { + InterventionInventoryScope, + InterventionInventorySnapshot, +} from '@features/organization/features/interventions/models'; +import type { + DeclareInventoryConsumptionInput, + InventoryConsumptionIntent, +} from '@features/organization/features/inventory/models'; + +/** + * Interface InterventionInventoryState + * @interface InterventionInventoryState + * + * @description + * Account-owned device and server facts have independent persistence states. + */ +export interface InterventionInventoryState { + /** + * Property scope + * @readonly + * + * @description + * Account, session and workspace authorized for the current inventory view. + * + * @type {InterventionInventoryScope | null} + */ + readonly scope: InterventionInventoryScope | null; + + /** + * Property snapshot + * @readonly + * + * @description + * Durable catalog and server facts; individual receipts never imply a complete catalog. + * + * @type {InterventionInventorySnapshot | null} + */ + readonly snapshot: InterventionInventorySnapshot | null; + + /** + * Property readCallState + * @readonly + * + * @description + * Request state for authorized snapshot restoration and complete server preparation. + * + * @type {CallState} + */ + readonly readCallState: CallState; + + /** + * Property queueCallState + * @readonly + * + * @description + * Request state for the durable write of a stable physical declaration. + * + * @type {CallState} + */ + readonly queueCallState: CallState; + + /** + * Property localIntents + * @readonly + * + * @description + * Unacknowledged local declarations remain distinct from server stock confirmation. + * + * @type {readonly InventoryConsumptionIntent[]} + */ + readonly localIntents: readonly InventoryConsumptionIntent[]; + + /** + * Property acceptedOperationId + * @readonly + * + * @description + * Matching operation whose device write completed, allowing its form input to clear. + * + * @type {string | null} + */ + readonly acceptedOperationId: string | null; + + /** + * Property unpersistedInput + * @readonly + * + * @description + * Submitted physical fact retained in memory while durable persistence remains unresolved. + * + * @type {DeclareInventoryConsumptionInput | null} + */ + readonly unpersistedInput: DeclareInventoryConsumptionInput | null; + + /** + * Property fromDevice + * @readonly + * + * @description + * Indicates restored device data rather than a completed current server preparation. + * + * @type {boolean} + */ + readonly fromDevice: boolean; +} diff --git a/src/app/features/organization/features/interventions/state/intervention-inventory/testing/intervention-inventory.store.spec.ts b/src/app/features/organization/features/interventions/state/intervention-inventory/testing/intervention-inventory.store.spec.ts new file mode 100644 index 000000000..d8ea0213f --- /dev/null +++ b/src/app/features/organization/features/interventions/state/intervention-inventory/testing/intervention-inventory.store.spec.ts @@ -0,0 +1,430 @@ +import { DOCUMENT, PLATFORM_ID, signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { ConnectivityService } from '@core/connectivity'; +import { InterventionOfflineService } from '@features/organization/features/interventions/data-access'; +import type { + InterventionInventoryScope, + InterventionInventorySnapshot, + InterventionOutboxOperation, + InterventionOutboxOperationFor, +} from '@features/organization/features/interventions/models'; +import { InterventionInventoryService } from '@features/organization/features/interventions/services/intervention-inventory'; +import type { + DeclareInventoryConsumptionInput, + InventoryConsumptionOutput, +} from '@features/organization/features/inventory/models'; +import { InterventionInventoryStore } from '../intervention-inventory.store'; + +const scope: InterventionInventoryScope = { + organizationId: 'org-1', + interventionId: 'intervention-1', + accountId: 'account-1', + sessionRevision: 1, +}; +const consumption: DeclareInventoryConsumptionInput = { + clientOperationId: '11111111-1111-4111-8111-111111111111', + interventionId: scope.interventionId, + partId: 'part-1', + warehouseId: 'warehouse-1', + quantity: '2.000000', + workItemId: 'work-item-1', + equipmentId: 'equipment-1', + occurredAt: '2026-10-06T10:00:00.123Z', +}; +const receipt: InventoryConsumptionOutput = { + '@id': '/api/organizations/org-1/inventory-consumptions/receipt-1', + '@type': 'InventoryConsumption', + id: 'receipt-1', + partId: consumption.partId, + warehouseId: consumption.warehouseId, + quantity: consumption.quantity, + interventionId: consumption.interventionId, + workItemId: consumption.workItemId, + equipmentId: consumption.equipmentId, + actorId: scope.accountId, + occurredAt: consumption.occurredAt, + status: 'received_pending', + reason: 'insufficient_stock', + late: false, + replayed: false, +}; +const snapshot: InterventionInventorySnapshot = { + version: 1, + accountId: scope.accountId, + organizationId: scope.organizationId, + interventionId: scope.interventionId, + capturedAt: '2026-10-06T10:00:00.123Z', + catalogComplete: true, + declarationsComplete: true, + parts: [ + { + '@id': '/api/organizations/org-1/inventory-parts/part-1', + '@type': 'InventoryPart', + id: 'part-1', + code: 'SEAL', + label: 'Safety seal', + unit: 'piece', + kind: 'part', + archived: false, + }, + ], + warehouses: [ + { + '@id': '/api/organizations/org-1/inventory-warehouses/warehouse-1', + '@type': 'InventoryWarehouse', + id: 'warehouse-1', + code: 'VAN', + name: 'Technician van', + archived: false, + }, + ], + declarations: [receipt], +}; +const queued: InterventionOutboxOperationFor<'inventory-consumption.declare'> = { + id: `inventory:${consumption.clientOperationId}`, + interventionId: consumption.interventionId, + type: 'inventory-consumption.declare', + payload: { ...consumption, actorId: scope.accountId, clientId: consumption.clientOperationId }, + createdAt: consumption.occurredAt, + status: 'pending', + error: null, +}; +const flush = (): Promise => + Array.from({ length: 16 }).reduce>( + (pending) => pending.then(() => undefined), + Promise.resolve(), + ); + +describe('InterventionInventoryStore', () => { + let store: InstanceType; + const activeScope = signal(scope); + const canRead = signal(true); + const canConsume = signal(true); + const disconnected = signal(true); + const inventory = { + isCurrent: vi.fn<(captured: InterventionInventoryScope, consume?: boolean) => boolean>(), + loadSaved: + vi.fn< + (captured: InterventionInventoryScope) => Promise + >(), + refresh: + vi.fn<(captured: InterventionInventoryScope) => Promise>(), + }; + const offline = { + listOutbox: + vi.fn<(interventionId: string) => Promise>(), + queueInventoryConsumption: + vi.fn< + ( + organizationId: string, + input: DeclareInventoryConsumptionInput, + accountId: string, + current: () => boolean, + ) => Promise + >(), + }; + + beforeEach(() => { + vi.restoreAllMocks(); + vi.resetAllMocks(); + activeScope.set(scope); + canRead.set(true); + canConsume.set(true); + disconnected.set(true); + inventory.isCurrent.mockImplementation( + (captured, consume = false) => + canRead() && + (!consume || canConsume()) && + captured.organizationId === activeScope().organizationId && + captured.interventionId === activeScope().interventionId && + captured.accountId === activeScope().accountId && + captured.sessionRevision === activeScope().sessionRevision, + ); + inventory.loadSaved.mockResolvedValue(snapshot); + inventory.refresh.mockResolvedValue(snapshot); + offline.listOutbox.mockResolvedValue([]); + offline.queueInventoryConsumption.mockResolvedValue(undefined); + TestBed.configureTestingModule({ + providers: [ + InterventionInventoryStore, + { provide: InterventionInventoryService, useValue: inventory }, + { provide: InterventionOfflineService, useValue: offline }, + { provide: ConnectivityService, useValue: { isOffline: disconnected } }, + { provide: PLATFORM_ID, useValue: 'browser' }, + ], + }); + store = TestBed.inject(InterventionInventoryStore); + }); + + it('acknowledges a physical declaration only after the durable queue promise resolves', async () => { + store.load(scope); + await flush(); + let release: (() => void) | undefined; + offline.queueInventoryConsumption.mockImplementation( + () => + new Promise((resolve) => { + release = resolve; + }), + ); + store.declare({ scope, input: consumption }); + expect(store.queueCallState().status).toBe('pending'); + expect(store.acceptedOperationId()).toBeNull(); + expect(store.unpersistedInput()).toEqual(consumption); + expect(store.hasUnpersistedDeclaration()).toBe(true); + expect(offline.queueInventoryConsumption).toHaveBeenCalledExactlyOnceWith( + 'org-1', + consumption, + 'account-1', + expect.any(Function), + ); + expect(offline.queueInventoryConsumption.mock.calls[0]?.[3]()).toBe(true); + + offline.listOutbox.mockResolvedValue([queued]); + release?.(); + await flush(); + expect(store.queueCallState().status).toBe('success'); + expect(store.acceptedOperationId()).toBe(consumption.clientOperationId); + expect(store.unpersistedInput()).toBeNull(); + expect(store.hasUnpersistedDeclaration()).toBe(false); + expect(store.localIntents()).toEqual([{ input: consumption, status: 'queued', error: null }]); + }); + + it('retains a volatile declaration after quota failure and retries its unchanged UUID and timestamp', async () => { + store.load(scope); + await flush(); + offline.queueInventoryConsumption.mockRejectedValueOnce( + new DOMException('Storage full.', 'QuotaExceededError'), + ); + store.declare({ scope, input: consumption }); + await flush(); + expect(store.queueCallState().status).toBe('error'); + expect(store.acceptedOperationId()).toBeNull(); + expect(store.unpersistedInput()).toEqual(consumption); + expect(store.hasUnpersistedDeclaration()).toBe(true); + expect(store.localIntents()).toEqual([]); + + offline.listOutbox.mockResolvedValue([queued]); + const retry = store.unpersistedInput(); + if (!retry) throw new Error('Failed physical declaration must remain available for retry.'); + store.declare({ scope, input: retry }); + await flush(); + expect(offline.queueInventoryConsumption.mock.calls.map((call) => call.slice(0, 3))).toEqual([ + ['org-1', consumption, 'account-1'], + ['org-1', consumption, 'account-1'], + ]); + expect(store.acceptedOperationId()).toBe(consumption.clientOperationId); + expect(store.hasUnpersistedDeclaration()).toBe(false); + }); + + it('prevents browser unload while a submitted declaration remains volatile and releases it after persistence', async () => { + const view = TestBed.inject(DOCUMENT).defaultView; + if (!view) throw new Error('Browser test requires a window.'); + store.load(scope); + await flush(); + offline.queueInventoryConsumption.mockRejectedValueOnce(new Error('Storage full.')); + store.declare({ scope, input: consumption }); + await flush(); + TestBed.tick(); + const blocked = new Event('beforeunload', { cancelable: true }); + expect(view.dispatchEvent(blocked)).toBe(false); + expect(blocked.defaultPrevented).toBe(true); + + store.declare({ scope, input: consumption }); + await flush(); + TestBed.tick(); + const permitted = new Event('beforeunload', { cancelable: true }); + expect(view.dispatchEvent(permitted)).toBe(true); + expect(permitted.defaultPrevented).toBe(false); + }); + + it('keeps the durable acknowledgement even when refreshing the local queue later fails', async () => { + store.load(scope); + await flush(); + offline.listOutbox.mockRejectedValueOnce(new Error('Local queue could not be read.')); + store.declare({ scope, input: consumption }); + await flush(); + expect(store.acceptedOperationId()).toBe(consumption.clientOperationId); + expect(store.unpersistedInput()).toBeNull(); + expect(store.queueCallState().status).toBe('success'); + expect(store.readCallState().status).toBe('error'); + }); + + it.each(['account', 'organization', 'intervention', 'session', 'permission'] as const)( + 'suppresses a late queue acknowledgement after the %s changes', + async (change) => { + store.load(scope); + await flush(); + let release: (() => void) | undefined; + offline.queueInventoryConsumption.mockImplementation( + () => + new Promise((resolve) => { + release = resolve; + }), + ); + store.declare({ scope, input: consumption }); + if (change === 'account') activeScope.set({ ...scope, accountId: 'account-2' }); + if (change === 'organization') activeScope.set({ ...scope, organizationId: 'org-2' }); + if (change === 'intervention') + activeScope.set({ ...scope, interventionId: 'intervention-2' }); + if (change === 'session') activeScope.set({ ...scope, sessionRevision: 2 }); + if (change === 'permission') canRead.set(false); + expect(offline.queueInventoryConsumption.mock.calls[0]?.[3]()).toBe(false); + store.load(null); + release?.(); + await flush(); + expect(store.scope()).toBeNull(); + expect(store.acceptedOperationId()).toBeNull(); + expect(store.localIntents()).toEqual([]); + expect(store.entities()).toEqual([]); + expect(store.queueCallState().status).toBe('idle'); + }, + ); + + it.each([ + 'incomplete', + 'archived part', + 'archived warehouse', + 'unknown part', + 'wrong intervention', + 'denied execution', + ] as const)('does not queue a declaration with %s eligibility', async (reason) => { + if (reason === 'incomplete') + inventory.loadSaved.mockResolvedValue({ ...snapshot, catalogComplete: false }); + if (reason === 'archived part') + inventory.loadSaved.mockResolvedValue({ + ...snapshot, + parts: snapshot.parts.map((part) => ({ ...part, archived: true })), + }); + if (reason === 'archived warehouse') + inventory.loadSaved.mockResolvedValue({ + ...snapshot, + warehouses: snapshot.warehouses.map((warehouse) => ({ ...warehouse, archived: true })), + }); + store.load(scope); + await flush(); + if (reason === 'denied execution') canConsume.set(false); + const input = + reason === 'unknown part' + ? { ...consumption, partId: 'unknown-part' } + : reason === 'wrong intervention' + ? { ...consumption, interventionId: 'intervention-2' } + : consumption; + store.declare({ scope, input }); + await flush(); + expect(offline.queueInventoryConsumption).not.toHaveBeenCalled(); + expect(store.acceptedOperationId()).toBeNull(); + expect(store.hasUnpersistedDeclaration()).toBe(false); + }); + + it('restores a complete device catalog and this actor’s queued or failed declarations while offline', async () => { + const failed = { + ...queued, + id: 'inventory:failed', + status: 'failed' as const, + error: 'Reference requires review.', + payload: { ...queued.payload, clientOperationId: 'failed-uuid' }, + }; + offline.listOutbox.mockResolvedValue([ + queued, + failed, + { + ...queued, + id: 'inventory:other-actor', + payload: { ...queued.payload, actorId: 'account-2' }, + }, + { + id: 'comment', + interventionId: scope.interventionId, + type: 'comment.create', + payload: { body: 'A comment' }, + createdAt: consumption.occurredAt, + status: 'pending', + }, + ]); + store.load(scope); + await flush(); + expect(store.catalogReady()).toBe(true); + expect(store.fromDevice()).toBe(true); + expect(store.readCallState().status).toBe('success'); + expect(inventory.refresh).not.toHaveBeenCalled(); + expect(store.localIntents()).toEqual([ + { input: consumption, status: 'queued', error: null }, + { + input: { ...consumption, clientOperationId: 'failed-uuid' }, + status: 'failed', + error: 'Reference requires review.', + }, + ]); + expect(store.entities()).toEqual([receipt]); + expect(store.unresolvedCount()).toBe(1); + expect(store.entities()[0]?.status).toBe('received_pending'); + }); + + it('exposes a receipt-only snapshot without declaring its offline catalog usable', async () => { + inventory.loadSaved.mockResolvedValue({ + ...snapshot, + catalogComplete: false, + declarationsComplete: false, + parts: [], + warehouses: [], + }); + store.load(scope); + await flush(); + expect(store.catalogReady()).toBe(false); + expect(store.entities()).toEqual([receipt]); + expect(store.unresolvedCount()).toBe(1); + }); + + it('retains a saved complete snapshot when the online refresh fails', async () => { + disconnected.set(false); + inventory.refresh.mockRejectedValueOnce(new Error('Inventory API unavailable.')); + store.load(scope); + await flush(); + expect(store.readCallState().status).toBe('error'); + expect(store.snapshot()).toEqual(snapshot); + expect(store.entities()).toEqual([receipt]); + expect(store.catalogReady()).toBe(true); + expect(store.fromDevice()).toBe(true); + }); + + it('does not expose a late saved snapshot after the workspace has been cleared', async () => { + let release: ((saved: InterventionInventorySnapshot) => void) | undefined; + inventory.loadSaved.mockImplementation( + () => + new Promise((resolve) => { + release = resolve; + }), + ); + store.load(scope); + expect(store.readCallState().status).toBe('pending'); + store.load(null); + release?.(snapshot); + await flush(); + expect(store.scope()).toBeNull(); + expect(store.snapshot()).toBeNull(); + expect(store.entities()).toEqual([]); + expect(offline.listOutbox).not.toHaveBeenCalled(); + expect(inventory.refresh).not.toHaveBeenCalled(); + }); + + it('does not expose a late network catalog after the permission is revoked', async () => { + disconnected.set(false); + let release: ((saved: InterventionInventorySnapshot) => void) | undefined; + inventory.refresh.mockImplementation( + () => + new Promise((resolve) => { + release = resolve; + }), + ); + store.load(scope); + await flush(); + expect(inventory.refresh).toHaveBeenCalledOnce(); + canRead.set(false); + store.load(scope); + release?.(snapshot); + await flush(); + expect(store.scope()).toBeNull(); + expect(store.snapshot()).toBeNull(); + expect(store.entities()).toEqual([]); + }); +}); diff --git a/src/app/features/organization/features/interventions/state/intervention-replacement-context/index.ts b/src/app/features/organization/features/interventions/state/intervention-replacement-context/index.ts new file mode 100644 index 000000000..1ceb02afb --- /dev/null +++ b/src/app/features/organization/features/interventions/state/intervention-replacement-context/index.ts @@ -0,0 +1,2 @@ +export { InterventionReplacementContextStore } from './intervention-replacement-context.store'; +export type { InterventionReplacementContextStoreType } from './intervention-replacement-context.store'; diff --git a/src/app/features/organization/features/interventions/state/intervention-replacement-context/intervention-replacement-context.store.ts b/src/app/features/organization/features/interventions/state/intervention-replacement-context/intervention-replacement-context.store.ts new file mode 100644 index 000000000..86a335563 --- /dev/null +++ b/src/app/features/organization/features/interventions/state/intervention-replacement-context/intervention-replacement-context.store.ts @@ -0,0 +1,132 @@ +import { isPlatformBrowser } from '@angular/common'; +import { effect, inject, PLATFORM_ID, untracked } from '@angular/core'; +import { tapResponse } from '@ngrx/operators'; +import { patchState, signalStore, withHooks, withMethods, withState } from '@ngrx/signals'; +import { rxMethod } from '@ngrx/signals/rxjs-interop'; +import { EMPTY, pipe, switchMap } from 'rxjs'; +import { + resetQuery, + setErrorQuery, + setPendingQuery, + setSuccessQuery, + toStoreError, + withQueryState, +} from '@core/request-state'; +import { AUTH_SESSION_PORT } from '@features/auth/ports'; +import { OrganizationPermissionService } from '@features/organization/access'; +import { InterventionOfflineService } from '@features/organization/features/interventions/data-access'; +import type { + InterventionReplacementContext, + InterventionReplacementContextRequest, +} from '@features/organization/features/interventions/models'; +import { InterventionReplacementContextService } from '@features/organization/features/interventions/services'; +import { ORGANIZATION_PERMISSION } from '@features/organization/models'; +import { ORGANIZATION_CONTEXT_PORT } from '@features/organization/ports'; + +/** + * Constant InterventionReplacementContextStore + * + * @description + * Reads the Equipment-owned replacement proof without changing equipment or inventing an identity. + * Scope changes cancel reads and discard proof before a result can be committed. + */ +export const InterventionReplacementContextStore = signalStore( + withQueryState(), + withState({ + scope: null as InterventionReplacementContextRequest | null, + accountId: null as string | null, + sessionRevision: null as number | null, + }), + withMethods( + ( + store, + service = inject(InterventionReplacementContextService), + session = inject(AUTH_SESSION_PORT), + offline = inject(InterventionOfflineService), + organization = inject(ORGANIZATION_CONTEXT_PORT), + permissions = inject(OrganizationPermissionService), + platformId = inject(PLATFORM_ID), + ) => { + const authorized = ( + scope: InterventionReplacementContextRequest, + accountId: string | null, + revision: number | null, + ): boolean => + Boolean(accountId) && + session.isAuthenticated() && + offline.publicationOwner() === accountId && + session.sessionRevision() === revision && + organization.selectedOrganizationId() === scope.organizationId && + !permissions.isLoadingPermissions() && + !permissions.permissionError() && + permissions.hasPermission(ORGANIZATION_PERMISSION.EQUIPMENT_READ); + return { + /** + * Method authorized + * + * @description + * Exposes the current proof's account and permission fence to the commit path. + * + * @returns {boolean} Whether the captured proof may still be used. + */ + authorized(): boolean { + const scope = store.scope(); + return scope !== null && authorized(scope, store.accountId(), store.sessionRevision()); + }, + /** + * Method load + * + * @description + * Refreshes a single task's original and successor while cancelling an earlier read. + * Null clears proof and cancels pending transport. + * + * @type {RxMethod} + */ + load: rxMethod( + pipe( + switchMap((scope) => { + patchState(store, resetQuery(), { scope, accountId: null, sessionRevision: null }); + if (!scope || !isPlatformBrowser(platformId)) return EMPTY; + const accountId = offline.publicationOwner(); + const revision = session.sessionRevision(); + if (!authorized(scope, accountId, revision)) return EMPTY; + patchState(store, setPendingQuery(), { accountId, sessionRevision: revision }); + return service.load(scope.organizationId, scope.equipmentId).pipe( + tapResponse({ + next: (data) => { + if (authorized(scope, accountId, revision)) + patchState(store, setSuccessQuery(data)); + }, + error: (error: unknown) => { + if (authorized(scope, accountId, revision)) + patchState(store, setErrorQuery(toStoreError(error))); + }, + }), + ); + }), + ), + ), + }; + }, + ), + withHooks((store) => ({ + onInit(): void { + effect(() => { + const scope = store.scope(); + if (scope && !store.authorized()) untracked(() => store.load(null)); + }); + }, + })), +); + +/** + * Type InterventionReplacementContextStoreType + * + * @description + * Component-scoped replacement proof query instance. + * + * @type {InterventionReplacementContextStoreType} + */ +export type InterventionReplacementContextStoreType = InstanceType< + typeof InterventionReplacementContextStore +>; diff --git a/src/app/features/organization/features/interventions/state/intervention-replacement-context/testing/intervention-replacement-context.store.spec.ts b/src/app/features/organization/features/interventions/state/intervention-replacement-context/testing/intervention-replacement-context.store.spec.ts new file mode 100644 index 000000000..156f93c27 --- /dev/null +++ b/src/app/features/organization/features/interventions/state/intervention-replacement-context/testing/intervention-replacement-context.store.spec.ts @@ -0,0 +1,107 @@ +import { signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { of, Subject, throwError } from 'rxjs'; +import { AUTH_SESSION_PORT } from '@features/auth/ports'; +import { OrganizationPermissionService } from '@features/organization/access'; +import type { EquipmentOutput } from '@features/organization/features/equipments/models'; +import { InterventionOfflineService } from '@features/organization/features/interventions/data-access'; +import type { InterventionReplacementContext } from '@features/organization/features/interventions/models'; +import { InterventionReplacementContextService } from '@features/organization/features/interventions/services'; +import { ORGANIZATION_CONTEXT_PORT } from '@features/organization/ports'; +import { InterventionReplacementContextStore } from '../intervention-replacement-context.store'; + +describe('InterventionReplacementContextStore', () => { + const scope = { organizationId: 'org-1', equipmentId: 'original', workItemId: 'task' }; + const proof: InterventionReplacementContext = { + original: { id: 'original' } as EquipmentOutput, + successor: { id: 'successor' } as EquipmentOutput, + }; + const account = signal('account'); + const organization = signal('org-1'); + const revision = signal(1); + const allowed = signal(true); + const authenticated = signal(true); + const service = { load: vi.fn() }; + beforeEach(() => { + account.set('account'); + organization.set('org-1'); + revision.set(1); + allowed.set(true); + authenticated.set(true); + service.load.mockReset().mockReturnValue(of(proof)); + TestBed.configureTestingModule({ + providers: [ + InterventionReplacementContextStore, + { provide: InterventionReplacementContextService, useValue: service }, + { provide: InterventionOfflineService, useValue: { publicationOwner: account } }, + { provide: ORGANIZATION_CONTEXT_PORT, useValue: { selectedOrganizationId: organization } }, + { + provide: AUTH_SESSION_PORT, + useValue: { sessionRevision: revision, isAuthenticated: authenticated }, + }, + { + provide: OrganizationPermissionService, + useValue: { + isLoadingPermissions: signal(false), + permissionError: signal(null), + hasPermission: () => allowed(), + }, + }, + ], + }); + }); + it('replaces proof only after a completed current read and cancels obsolete task reads', () => { + const store = TestBed.inject(InterventionReplacementContextStore); + const pending = new Subject(); + service.load.mockReturnValueOnce(pending); + store.load(scope); + expect(store.isQueryLoading()).toBe(true); + expect(store.queryData()).toBeNull(); + store.load({ ...scope, workItemId: 'next-task' }); + expect(pending.observed).toBe(false); + pending.next({ ...proof, successor: null }); + expect(store.queryData()).toEqual(proof); + expect(store.scope()?.workItemId).toBe('next-task'); + }); + it.each(['account', 'organization', 'session', 'permission', 'authentication'] as const)( + 'clears current proof and cancels pending work when %s changes', + (changed) => { + const store = TestBed.inject(InterventionReplacementContextStore); + store.load(scope); + TestBed.tick(); + expect(store.queryData()).toEqual(proof); + const pending = new Subject(); + service.load.mockReturnValueOnce(pending); + store.load(scope); + TestBed.tick(); + if (changed === 'account') account.set('other'); + if (changed === 'organization') organization.set('org-2'); + if (changed === 'session') revision.set(2); + if (changed === 'permission') allowed.set(false); + if (changed === 'authentication') authenticated.set(false); + TestBed.tick(); + expect(pending.observed).toBe(false); + expect(store.scope()).toBeNull(); + expect(store.queryData()).toBeNull(); + expect(store.authorized()).toBe(false); + }, + ); + it('retains a readable failure and explicitly retries without accepting stale proof', () => { + const store = TestBed.inject(InterventionReplacementContextStore); + service.load.mockReturnValueOnce(throwError(() => new Error('Offline'))); + store.load(scope); + expect(store.queryHasError()).toBe(true); + expect(store.queryData()).toBeNull(); + store.load(scope); + expect(store.queryHasError()).toBe(false); + expect(store.queryData()).toEqual(proof); + }); + it('never sends an unauthorized read', () => { + const store = TestBed.inject(InterventionReplacementContextStore); + allowed.set(false); + store.load(scope); + TestBed.tick(); + expect(service.load).not.toHaveBeenCalled(); + expect(store.scope()).toBeNull(); + }); +}); diff --git a/src/app/features/organization/features/interventions/state/intervention-time/intervention-time.store.ts b/src/app/features/organization/features/interventions/state/intervention-time/intervention-time.store.ts index d037a9468..213a6c393 100644 --- a/src/app/features/organization/features/interventions/state/intervention-time/intervention-time.store.ts +++ b/src/app/features/organization/features/interventions/state/intervention-time/intervention-time.store.ts @@ -12,7 +12,7 @@ import { import { withEntities, setAllEntities, removeAllEntities } from '@ngrx/signals/entities'; import { Dispatcher } from '@ngrx/signals/events'; import { rxMethod } from '@ngrx/signals/rxjs-interop'; -import { concatMap, EMPTY, from, map, pipe, switchMap } from 'rxjs'; +import { concatMap, EMPTY, from, map, mergeMap, pipe, switchMap } from 'rxjs'; import { idleCallState, pendingCallState, @@ -55,6 +55,13 @@ const initialState: InterventionTimeState = { draftPersistenceFailed: false, offline: false, historyUnavailable: false, + page: 1, + itemsPerPage: 30, + totalItems: null, + nextPage: null, + readVersion: 0, + historyCallStates: {}, + draftEntryCallState: idleCallState(), }; /** @@ -124,34 +131,98 @@ export const InterventionTimeStore = signalStore( })), withMethods((store, journal = inject(InterventionTimeJournalService)) => ({ /** - * Method load - * @method load + * Method reviewDraft + * @method reviewDraft * * @description - * Opens or refreshes one authorized journal; cancels obsolete reads. + * Resolves a saved correction target independently from the displayed entry page. + * Failed or hidden reads preserve the draft and require explicit retry. * * @access public + * + * @returns {void} Updates the independent current-entry review request state. + */ + reviewDraft: rxMethod( + pipe( + switchMap(() => { + const scope = store.scope(); + const draft = store.draft(); + if (!scope || !draft || draft.baseRevision === null || store.offline()) return EMPTY; + const readVersion = store.readVersion(); + patchState(store, { + draftEntryCallState: pendingCallState(store.draftEntryCallState().data), + }); + return journal.readEntry(scope, draft.id).pipe( + tapResponse({ + next: (value) => { + if ( + store.readVersion() === readVersion && + store.scope() === scope && + store.draft()?.id === draft.id + ) + patchState(store, { draftEntryCallState: successCallState(value.entry) }); + }, + error: (error: unknown) => { + if ( + store.readVersion() === readVersion && + store.scope() === scope && + store.draft()?.id === draft.id + ) + patchState(store, { + draftEntryCallState: errorCallState( + toStoreError(error), + store.draftEntryCallState().data, + ), + }); + }, + }), + ); + }), + ), + ), + })), + withMethods((store, journal = inject(InterventionTimeJournalService)) => ({ + /** + * Method _read + * @method _read + * + * @description + * Opens or refreshes one authorized journal; cancels obsolete reads. + * + * @access private * @since 1.0.0 * - * @param {InterventionTimeScope | null} scope - Selected task or dismissed sheet. + * @param {{ scope: InterventionTimeScope; page: number } | null} request - Selected journal + * page or dismissed sheet. * * @returns {void} */ - load: rxMethod( + _read: rxMethod<{ scope: InterventionTimeScope; page: number } | null>( pipe( - switchMap((scope) => { - if (!scope) { - patchState(store, initialState, removeAllEntities()); + switchMap((request) => { + const readVersion = store.readVersion() + 1; + if (!request) { + patchState(store, initialState, removeAllEntities(), { readVersion }); return EMPTY; } + const { scope, page } = request; const sameTask = store.scope()?.workItemId === scope.workItemId && - store.scope()?.actorId === scope.actorId; + store.scope()?.actorId === scope.actorId && + store.scope()?.interventionId === scope.interventionId && + (store.scope()?.manageOthers === true) === (scope.manageOthers === true); if (!sameTask) patchState(store, initialState, removeAllEntities()); - patchState(store, { scope, readCallState: pendingCallState() }); - return journal.read(scope).pipe( + patchState(store, { + scope, + page, + readVersion, + historyCallStates: {}, + draftEntryCallState: idleCallState(), + readCallState: pendingCallState(), + }); + return journal.read(scope, page).pipe( tapResponse({ - next: (value) => + next: (value) => { patchState(store, setAllEntities([...value.entries]), { readCallState: successCallState(null), persistedDraft: value.draft, @@ -160,7 +231,15 @@ export const InterventionTimeStore = signalStore( : { draft: value.draft }), offline: value.offline, historyUnavailable: value.historyUnavailable, - }), + page: value.page, + itemsPerPage: value.itemsPerPage, + totalItems: value.totalItems, + nextPage: value.nextPage, + }); + const draft = store.draft(); + if (draft && draft.baseRevision !== null && !store.entityMap()[draft.id]) + store.reviewDraft(); + }, error: (error: unknown) => patchState(store, { readCallState: errorCallState(toStoreError(error)) }), }), @@ -168,6 +247,131 @@ export const InterventionTimeStore = signalStore( }), ), ), + /** + * Method loadHistory + * @method loadHistory + * + * @description + * Reads revisions only after explicit expansion or continuation and retains prior pages on + * failure. + * + * @access public + * + * @param {{ entryId: string; more?: boolean }} request - Expanded entry or earlier-page + * continuation. + * + * @returns {void} Updates only the selected entry's independent history request state. + */ + loadHistory: rxMethod<{ entryId: string; more?: boolean }>( + pipe( + mergeMap(({ entryId, more }) => { + const scope = store.scope(); + const entry = store.entityMap()[entryId]; + const previous = store.historyCallStates()[entryId]; + if ( + !scope || + !entry || + entry.syncStatus || + store.offline() || + previous?.status === 'pending' + ) + return EMPTY; + if (!more && previous?.status === 'success') return EMPTY; + const beforeRevision = previous?.data?.nextBeforeRevision ?? undefined; + if (more && previous?.data && beforeRevision === undefined) return EMPTY; + const readVersion = store.readVersion(); + patchState(store, { + historyCallStates: { + ...store.historyCallStates(), + [entryId]: pendingCallState(previous?.data), + }, + }); + return journal.readVersions(scope, entryId, beforeRevision).pipe( + tapResponse({ + next: (value) => { + if (store.readVersion() !== readVersion || store.scope() !== scope) return; + const versions = new Map( + [...(previous?.data?.versions ?? []), ...value.versions].map((version) => [ + version.revision, + version, + ]), + ); + patchState(store, { + historyCallStates: { + ...store.historyCallStates(), + [entryId]: successCallState({ + ...value, + versions: [...versions.values()].toSorted( + (left, right) => right.revision - left.revision, + ), + }), + }, + }); + }, + error: (error: unknown) => { + if (store.readVersion() === readVersion && store.scope() === scope) + patchState(store, { + historyCallStates: { + ...store.historyCallStates(), + [entryId]: errorCallState(toStoreError(error), previous?.data), + }, + }); + }, + }), + ); + }), + ), + ), + })), + withMethods((store) => ({ + /** + * Method load + * @method load + * + * @description + * Refreshes the displayed journal page while a newly selected authority starts at page one. + * + * @access public + * + * @param {InterventionTimeScope | null} scope - Selected task or dismissed sheet. + * + * @returns {void} Starts a bounded cancellable journal read. + */ + load(scope: InterventionTimeScope | null): void { + const previous = store.scope(); + const page = + previous?.workItemId === scope?.workItemId && + previous?.actorId === scope?.actorId && + previous?.interventionId === scope?.interventionId && + (previous?.manageOthers === true) === (scope?.manageOthers === true) + ? store.page() + : 1; + store['_read'](scope ? { scope, page } : null); + }, + /** + * Method loadPage + * @method loadPage + * + * @description + * Navigates explicitly between bounded journal pages without consuming the user's draft. + * + * @access public + * + * @param {number} page - Positive target journal page. + * + * @returns {void} Starts a bounded cancellable page read. + */ + loadPage(page: number): void { + const scope = store.scope(); + if ( + !scope || + !Number.isSafeInteger(page) || + page < 1 || + store.writeCallState().status === 'pending' + ) + return; + store['_read']({ scope, page }); + }, })), withMethods( ( @@ -200,6 +404,8 @@ export const InterventionTimeStore = signalStore( const current = (): boolean => store.scope()?.workItemId === scope.workItemId && store.scope()?.actorId === scope.actorId && + store.scope()?.interventionId === scope.interventionId && + (store.scope()?.manageOthers === true) === (scope.manageOthers === true) && owner === offline.publicationOwner(); if (request.kind === 'draft') { return from( diff --git a/src/app/features/organization/features/interventions/state/intervention-time/models/intervention-time-state.interface.ts b/src/app/features/organization/features/interventions/state/intervention-time/models/intervention-time-state.interface.ts index c870eb84f..4c38c340f 100644 --- a/src/app/features/organization/features/interventions/state/intervention-time/models/intervention-time-state.interface.ts +++ b/src/app/features/organization/features/interventions/state/intervention-time/models/intervention-time-state.interface.ts @@ -2,6 +2,8 @@ import type { CallState } from '@core/request-state'; import type { InterventionTimeDraft, InterventionTimeScope, + InterventionTimeEntryVersionsOutput, + InterventionTimeEntryView, } from '@features/organization/features/interventions/models'; /** @@ -139,4 +141,97 @@ export interface InterventionTimeState { * @type {boolean} */ readonly historyUnavailable: boolean; + + /** + * Property page + * @readonly + * + * @description + * Current requested journal page. + * + * @access public + * + * @type {number} + */ + readonly page: number; + + /** + * Property itemsPerPage + * @readonly + * + * @description + * Bounded server journal page size. + * + * @access public + * + * @type {number} + */ + readonly itemsPerPage: number; + + /** + * Property totalItems + * @readonly + * + * @description + * Exact authorized saved entry count; null when offline metadata is unknown. + * + * @access public + * + * @type {number | null} + */ + readonly totalItems: number | null; + + /** + * Property nextPage + * @readonly + * + * @description + * Known next journal page. + * + * @access public + * + * @type {number | null} + */ + readonly nextPage: number | null; + + /** + * Property readVersion + * @readonly + * + * @description + * Monotonic read generation fencing obsolete history responses. + * + * @access public + * + * @type {number} + */ + readonly readVersion: number; + + /** + * Property historyCallStates + * @readonly + * + * @description + * Independent explicit revision-page request states, including retained pages on retry. + * + * @access public + * + * @type {Readonly>>>} + */ + readonly historyCallStates: Readonly< + Partial>> + >; + + /** + * Property draftEntryCallState + * @readonly + * + * @description + * Independent bounded read of a saved correction target outside the displayed journal page. + * + * @access public + * + * @type {CallState} + */ + readonly draftEntryCallState: CallState; } diff --git a/src/app/features/organization/features/interventions/state/intervention-time/testing/intervention-time.store.spec.ts b/src/app/features/organization/features/interventions/state/intervention-time/testing/intervention-time.store.spec.ts index 1a42d991b..e06654700 100644 --- a/src/app/features/organization/features/interventions/state/intervention-time/testing/intervention-time.store.spec.ts +++ b/src/app/features/organization/features/interventions/state/intervention-time/testing/intervention-time.store.spec.ts @@ -5,7 +5,12 @@ import { InterventionOfflineService, InterventionTimeRepository, } from '@features/organization/features/interventions/data-access'; -import type { InterventionTimeJournalView } from '@features/organization/features/interventions/models'; +import type { + InterventionTimeJournalView, + InterventionTimeEntryView, + InterventionTimeEntryVersionsOutput, + InterventionTimeEntryVersion, +} from '@features/organization/features/interventions/models'; import { InterventionTimeJournalService } from '@features/organization/features/interventions/services/intervention-time-journal'; import { InterventionTimeStore, type InterventionTimeStoreType } from '../intervention-time.store'; @@ -15,6 +20,16 @@ function unload(): Event { return event; } +const version = (revision: number): InterventionTimeEntryVersion => ({ + revision, + workedOn: '2026-09-16', + minutes: 120, + note: null, + cancelled: false, + actorId: 'member', + recordedAt: '2026-09-16T12:00:00Z', +}); + describe('InterventionTimeStore', () => { const scope = { interventionId: 'intervention', workItemId: 'task', actorId: 'member' }; const draft = { @@ -30,10 +45,42 @@ describe('InterventionTimeStore', () => { draft: null, offline: false, historyUnavailable: false, + page: 1, + itemsPerPage: 30, + totalItems: 0, + nextPage: null, }; + const entry: InterventionTimeEntryView = { + id: 'entry', + workItemId: 'task', + memberId: 'member', + workedOn: '2026-09-16', + minutes: 120, + revision: 3, + cancelled: false, + createdBy: 'member', + updatedBy: 'member', + createdAt: '2026-09-16T12:00:00Z', + updatedAt: '2026-09-16T12:00:00Z', + versions: [version(3)], + totalVersions: 3, + nextBeforeRevision: 3, + }; + const history = ( + revisions: readonly number[], + nextBeforeRevision: number | null, + ): InterventionTimeEntryVersionsOutput => ({ + '@id': '/time-entries/entry/versions', + '@type': 'InterventionTimeEntryVersions', + id: 'entry', + versions: revisions.map(version), + totalItems: 3, + itemsPerPage: 2, + nextBeforeRevision, + }); let store: InterventionTimeStoreType; let owner: string; - const journal = { read: vi.fn(), write: vi.fn() }; + const journal = { read: vi.fn(), readVersions: vi.fn(), readEntry: vi.fn(), write: vi.fn() }; const repository = { saveDraft: vi.fn(), clearDraft: vi.fn() }; function createStore(platform = 'browser'): void { TestBed.configureTestingModule({ @@ -50,6 +97,8 @@ describe('InterventionTimeStore', () => { beforeEach(() => { owner = 'account'; journal.read.mockReturnValue(of(view)); + journal.readVersions.mockReturnValue(of(history([3, 2], 2))); + journal.readEntry.mockReturnValue(of({ id: 'entry', entry })); journal.write.mockReturnValue(of('remote')); repository.saveDraft.mockResolvedValue(undefined); repository.clearDraft.mockResolvedValue(undefined); @@ -60,6 +109,145 @@ describe('InterventionTimeStore', () => { vi.restoreAllMocks(); vi.clearAllMocks(); }); + it('loads one entry page without eagerly loading history and replaces entries on explicit navigation', () => { + journal.read + .mockReturnValueOnce(of({ ...view, entries: [entry], totalItems: 31, nextPage: 2 })) + .mockReturnValueOnce( + of({ ...view, entries: [{ ...entry, id: 'older' }], page: 2, totalItems: 31 }), + ); + store.load(scope); + expect(journal.read).toHaveBeenLastCalledWith(scope, 1); + expect(journal.readVersions).not.toHaveBeenCalled(); + expect(store.totalItems()).toBe(31); + expect(store.nextPage()).toBe(2); + store.loadPage(2); + expect(journal.read).toHaveBeenLastCalledWith(scope, 2); + expect(store.entities().map((row) => row.id)).toEqual(['older']); + expect(store.page()).toBe(2); + expect(store.nextPage()).toBeNull(); + }); + it('resolves a saved correction target outside page one with a single bounded current-entry read', () => { + const correction = { ...draft, id: 'entry', baseRevision: 1 }; + journal.read.mockReturnValueOnce( + of({ ...view, draft: correction, totalItems: 60, nextPage: 2 }), + ); + store.load(scope); + expect(journal.read).toHaveBeenCalledExactlyOnceWith(scope, 1); + expect(journal.readEntry).toHaveBeenCalledExactlyOnceWith(scope, 'entry'); + expect(journal.readVersions).not.toHaveBeenCalled(); + expect(store.entities()).toEqual([]); + expect(store.draft()).toEqual(correction); + expect(store.draftEntryCallState().data?.revision).toBe(3); + }); + it('preserves an off-page saved correction on a hidden current-entry response and permits explicit retry', () => { + const correction = { ...draft, id: 'entry', baseRevision: 1 }; + journal.read.mockReturnValueOnce( + of({ ...view, draft: correction, totalItems: 60, nextPage: 2 }), + ); + journal.readEntry.mockReturnValueOnce( + throwError(() => ({ status: 404, message: 'Entry hidden' })), + ); + store.load(scope); + expect(store.draftEntryCallState().status).toBe('error'); + expect(store.draft()).toEqual(correction); + store.reviewDraft(); + expect(store.draftEntryCallState().status).toBe('success'); + expect(store.draft()).toEqual(correction); + }); + it('excludes a late saved-correction target after journal authority changes', () => { + const correction = { ...draft, id: 'entry', baseRevision: 1 }; + const stale = new Subject<{ id: string; entry: InterventionTimeEntryView }>(); + journal.read.mockReturnValueOnce(of({ ...view, draft: correction })); + journal.readEntry.mockReturnValueOnce(stale); + store.load({ ...scope, manageOthers: true }); + store.load({ ...scope, manageOthers: false }); + stale.next({ id: 'entry', entry }); + expect(store.draftEntryCallState().status).toBe('idle'); + expect(store.draftEntryCallState().data).toBeNull(); + }); + it('retries the same failed entry page without reverting to page one', () => { + store.load(scope); + journal.read + .mockReturnValueOnce(throwError(() => new Error('Page unavailable'))) + .mockReturnValueOnce(of({ ...view, page: 2, totalItems: 31 })); + store.loadPage(2); + expect(store.readCallState().status).toBe('error'); + expect(store.page()).toBe(2); + store.load(scope); + expect(journal.read).toHaveBeenLastCalledWith(scope, 2); + expect(store.readCallState().status).toBe('success'); + }); + it('cancels obsolete page reads and keeps durable input during navigation', async () => { + store.load(scope); + store.saveDraft({ scope, draft }); + await vi.waitFor(() => expect(store.draftCallState().status).toBe('success')); + const stale = new Subject(); + journal.read.mockReturnValueOnce(stale).mockReturnValueOnce(of({ ...view, page: 3, draft })); + store.loadPage(2); + store.loadPage(3); + expect(stale.observed).toBe(false); + expect(store.page()).toBe(3); + expect(store.draft()).toEqual(draft); + }); + it('loads and appends history only on explicit expansion and cursor continuation', () => { + journal.read.mockReturnValue(of({ ...view, entries: [entry] })); + store.load(scope); + expect(journal.readVersions).not.toHaveBeenCalled(); + store.loadHistory({ entryId: 'entry' }); + expect(journal.readVersions).toHaveBeenLastCalledWith(scope, 'entry', undefined); + store.loadHistory({ entryId: 'entry' }); + expect(journal.readVersions).toHaveBeenCalledOnce(); + journal.readVersions.mockReturnValueOnce(of(history([1], null))); + store.loadHistory({ entryId: 'entry', more: true }); + expect(journal.readVersions).toHaveBeenLastCalledWith(scope, 'entry', 2); + expect(store.historyCallStates()['entry']?.data?.versions.map((row) => row.revision)).toEqual([ + 3, 2, 1, + ]); + expect(store.historyCallStates()['entry']?.data?.totalItems).toBe(3); + expect(store.historyCallStates()['entry']?.data?.nextBeforeRevision).toBeNull(); + store.loadHistory({ entryId: 'entry', more: true }); + expect(journal.readVersions).toHaveBeenCalledTimes(2); + }); + it('retains loaded history pages and retries an identical failed continuation cursor', () => { + journal.read.mockReturnValue(of({ ...view, entries: [entry] })); + store.load(scope); + store.loadHistory({ entryId: 'entry' }); + journal.readVersions + .mockReturnValueOnce(throwError(() => new Error('History unavailable'))) + .mockReturnValueOnce(of(history([1], null))); + store.loadHistory({ entryId: 'entry', more: true }); + expect(store.historyCallStates()['entry']?.status).toBe('error'); + expect(store.historyCallStates()['entry']?.data?.versions.map((row) => row.revision)).toEqual([ + 3, 2, + ]); + expect(store.historyCallStates()['entry']?.data?.nextBeforeRevision).toBe(2); + store.loadHistory({ entryId: 'entry', more: true }); + expect(journal.readVersions.mock.calls.slice(-2)).toEqual([ + [scope, 'entry', 2], + [scope, 'entry', 2], + ]); + expect(store.historyCallStates()['entry']?.status).toBe('success'); + }); + it('ignores late history after leaving and reopening the same journal', () => { + journal.read.mockReturnValue(of({ ...view, entries: [entry] })); + const stale = new Subject(); + journal.readVersions.mockReturnValueOnce(stale); + store.load(scope); + store.loadHistory({ entryId: 'entry' }); + store.load(null); + store.load(scope); + stale.next(history([3, 2, 1], null)); + expect(store.historyCallStates()).toEqual({}); + }); + it('keeps revision history unavailable offline and clears broad history after authority reduction', () => { + journal.read.mockReturnValueOnce(of({ ...view, entries: [entry], offline: true })); + store.load({ ...scope, manageOthers: true }); + store.loadHistory({ entryId: 'entry' }); + expect(journal.readVersions).not.toHaveBeenCalled(); + store.load({ ...scope, manageOthers: false }); + expect(store.page()).toBe(1); + expect(store.historyCallStates()).toEqual({}); + }); it('cancels obsolete journal reads', () => { const pending = new Subject(); journal.read.mockReturnValueOnce(pending); @@ -68,6 +256,46 @@ describe('InterventionTimeStore', () => { expect(pending.observed).toBe(false); expect(store.scope()?.workItemId).toBe('next'); }); + it('does not reopen broad journal authority when an earlier manager write finishes', () => { + const broad = { ...scope, manageOthers: true }; + const restricted = { ...scope, manageOthers: false }; + const accepted = new Subject<'remote' | 'queued'>(); + journal.write.mockReturnValueOnce(accepted); + store.load(broad); + store.write({ scope: broad, command: { kind: 'cancel', id: 'entry', revision: 1 } }); + store.load(restricted); + accepted.next('remote'); + expect(store.scope()).toEqual(restricted); + expect(journal.read).toHaveBeenLastCalledWith(restricted, 1); + expect(store.writeCallState().status).toBe('idle'); + }); + it('ignores earlier draft persistence after the same task loses broad journal authority', async () => { + const broad = { ...scope, manageOthers: true }; + const restricted = { ...scope, manageOthers: false }; + let release = vi.fn<() => void>(); + repository.saveDraft.mockReturnValueOnce( + new Promise((resolve) => { + release = vi.fn(resolve); + }), + ); + store.load(broad); + store.saveDraft({ scope: broad, draft }); + store.load(restricted); + release(); + await Promise.resolve(); + await Promise.resolve(); + expect(store.scope()).toEqual(restricted); + expect(store.draftCallState().status).toBe('idle'); + expect(store.persistedDraft()).toBeNull(); + }); + it('cancels obsolete journal reads after a dismissed sheet', () => { + const pending = new Subject(); + journal.read.mockReturnValueOnce(pending); + store.load(scope); + store.load(null); + expect(pending.observed).toBe(false); + expect(store.scope()).toBeNull(); + }); it('serializes the last draft persistence before submission', async () => { let release = vi.fn<() => void>(); repository.saveDraft.mockReturnValueOnce( diff --git a/src/app/features/organization/features/interventions/state/intervention-workspace/intervention-workspace.store.ts b/src/app/features/organization/features/interventions/state/intervention-workspace/intervention-workspace.store.ts index 9c3be28cc..20e501b4b 100644 --- a/src/app/features/organization/features/interventions/state/intervention-workspace/intervention-workspace.store.ts +++ b/src/app/features/organization/features/interventions/state/intervention-workspace/intervention-workspace.store.ts @@ -1,6 +1,13 @@ -import { computed, inject } from '@angular/core'; +import { computed, effect, inject } from '@angular/core'; import { tapResponse } from '@ngrx/operators'; -import { patchState, signalStore, withComputed, withMethods, withState } from '@ngrx/signals'; +import { + patchState, + signalStore, + withComputed, + withHooks, + withMethods, + withState, +} from '@ngrx/signals'; import { Dispatcher } from '@ngrx/signals/events'; import { rxMethod } from '@ngrx/signals/rxjs-interop'; import { @@ -9,6 +16,7 @@ import { defer, EMPTY, exhaustMap, + firstValueFrom, filter, finalize, forkJoin, @@ -36,6 +44,7 @@ import { toStoreFailureEventPayload, type StoreError, } from '@core/request-state'; +import { EquipmentTypeCatalogStore } from '@features/organization/features/equipments'; import { FacilityService } from '@features/organization/features/facilities/data-access'; import type { FacilityOutput } from '@features/organization/features/facilities/models'; import { @@ -51,12 +60,18 @@ import type { InterventionIssueOutput, InterventionOutboxOperation, InterventionOutput, + InterventionEquipmentCatalogSnapshot, InterventionQueuedAttachment, InterventionTransitionRequest, InterventionWorkItemOutput, } from '@features/organization/features/interventions/models'; +import { InterventionEquipmentCatalogService } from '@features/organization/features/interventions/services/intervention-equipment-catalog'; +import { InterventionInventoryService } from '@features/organization/features/interventions/services/intervention-inventory'; import { InterventionWorkspaceOptimisticService } from '@features/organization/features/interventions/services/intervention-workspace-optimistic'; -import { projectInterventionWorkspace } from '@features/organization/features/interventions/utils'; +import { + projectInterventionWorkspace, + projectInterventionWorkItemExecutionResult, +} from '@features/organization/features/interventions/utils'; import { workloadAssessmentFromError } from '@features/organization/features/workload/utils'; import { interventionWorkspaceStoreEvents } from './events'; import type { @@ -86,6 +101,7 @@ import type { * @constant INITIAL_STATE */ const INITIAL_STATE: InterventionWorkspaceState = { + equipmentCatalogSnapshot: null, planningConfirmation: null, contextId: null, loadGeneration: 0, @@ -411,6 +427,8 @@ export const InterventionWorkspaceStore = signalStore( InterventionWorkspaceOptimisticService, ), dispatcher = inject(Dispatcher), + equipmentCatalog = inject(InterventionEquipmentCatalogService), + inventory = inject(InterventionInventoryService), ) => { let contextGeneration = 0; const teamAssignments = new Set(); @@ -433,6 +451,7 @@ export const InterventionWorkspaceStore = signalStore( workItems: workspace.workItems, changes: workspace.changes, issues: { member: workspace.issues }, + equipmentCatalog: workspace.equipmentCatalog, fromCache: true, }; }), @@ -464,14 +483,17 @@ export const InterventionWorkspaceStore = signalStore( changes, issues, fromCache, + equipmentCatalog: cachedCatalog, }: { intervention: InterventionOutput; workItems: readonly InterventionWorkItemOutput[]; changes: readonly InterventionChangeOutput[]; issues: { readonly member: readonly InterventionIssueOutput[] }; fromCache: boolean; + equipmentCatalog?: InterventionEquipmentCatalogSnapshot; }): void => { patchState(store, { + equipmentCatalogSnapshot: cachedCatalog ?? null, intervention, workItems, changes, @@ -481,7 +503,21 @@ export const InterventionWorkspaceStore = signalStore( loadCallState: successCallState(null), }); if (fromCache) return; // Re-saving the snapshot we just read would only refresh its timestamp and lie about its age. - void offline.saveWorkspace(intervention, workItems, changes, issues.member); + const owner = equipmentCatalog.accountId(); + const generation = store.loadGeneration(); + void offline + .saveWorkspace(intervention, workItems, changes, issues.member) + .then(() => firstValueFrom(equipmentCatalog.capture(intervention, owner))) + .then((snapshot) => { + if ( + snapshot && + store.contextId() === intervention.id && + store.loadGeneration() === generation + ) + patchState(store, { equipmentCatalogSnapshot: snapshot }); + }) + .then(() => firstValueFrom(inventory.capture(intervention, owner))) + .catch(() => undefined); }; const loadFailure = (error: unknown) => @@ -545,6 +581,7 @@ export const InterventionWorkspaceStore = signalStore( const generation = store.loadGeneration() + 1; patchState(store, { contextId: interventionId, + equipmentCatalogSnapshot: null, loadGeneration: generation, intervention: null, workItems: [], @@ -1254,8 +1291,8 @@ export const InterventionWorkspaceStore = signalStore( * @method updateWorkItem * * @description - * Persists explicit effort, period or assignment edits; offline proposals remain unverified - * until replay. + * Persists effort, period, assignment and execution-result edits; offline proposals remain + * unverified until replay. * * @access public * @since 1.0.0 @@ -1290,6 +1327,10 @@ export const InterventionWorkspaceStore = signalStore( map((): InterventionWorkItemOutput => ({ ...item, ...input, + executionResult: + input.executionResult !== undefined + ? projectInterventionWorkItemExecutionResult(item, input.executionResult) + : item.executionResult, assigneeProfile: input.assignee !== undefined && input.assignee !== item.assignee ? null @@ -2251,6 +2292,15 @@ export const InterventionWorkspaceStore = signalStore( } }, })), + withHooks((store) => { + const catalog = inject(InterventionEquipmentCatalogService); + const target = inject(EquipmentTypeCatalogStore, { optional: true }); + return { + onInit(): void { + if (target) effect(() => catalog.restore(store.equipmentCatalogSnapshot(), target)); + }, + }; + }), ); /** diff --git a/src/app/features/organization/features/interventions/state/intervention-workspace/models/intervention-workspace-state.interface.ts b/src/app/features/organization/features/interventions/state/intervention-workspace/models/intervention-workspace-state.interface.ts index 35309a251..e54454cb6 100644 --- a/src/app/features/organization/features/interventions/state/intervention-workspace/models/intervention-workspace-state.interface.ts +++ b/src/app/features/organization/features/interventions/state/intervention-workspace/models/intervention-workspace-state.interface.ts @@ -6,6 +6,7 @@ import type { InterventionChangeOutput, InterventionIssueOutput, InterventionOutput, + InterventionEquipmentCatalogSnapshot, InterventionQueuedAttachment, InterventionWorkItemOutput, } from '@features/organization/features/interventions/models'; @@ -21,6 +22,20 @@ import type { InterventionPlanningConfirmation } from './intervention-planning-c * @since 1.0.0 */ export interface InterventionWorkspaceState { + /** + * Property equipmentCatalogSnapshot + * @readonly + * + * @description + * Complete catalog received with this workspace; visible restoration rechecks current access. + * + * @access public + * @since unreleased + * + * @type {InterventionEquipmentCatalogSnapshot | null} + */ + readonly equipmentCatalogSnapshot: InterventionEquipmentCatalogSnapshot | null; + /** * Property planningConfirmation * @readonly diff --git a/src/app/features/organization/features/interventions/state/intervention-workspace/testing/intervention-workspace-catalog.store.spec.ts b/src/app/features/organization/features/interventions/state/intervention-workspace/testing/intervention-workspace-catalog.store.spec.ts new file mode 100644 index 000000000..ef9529154 --- /dev/null +++ b/src/app/features/organization/features/interventions/state/intervention-workspace/testing/intervention-workspace-catalog.store.spec.ts @@ -0,0 +1,145 @@ +import { HttpErrorResponse } from '@angular/common/http'; +import { ApplicationRef, signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { of, throwError } from 'rxjs'; +import { ConnectivityService } from '@core/connectivity'; +import { AUTH_SESSION_PORT } from '@features/auth/ports'; +import { OrganizationPermissionService } from '@features/organization/access'; +import { EquipmentTypeCatalogStore } from '@features/organization/features/equipments'; +import { EquipmentTypeService } from '@features/organization/features/equipments/data-access'; +import type { EquipmentTypeOutput } from '@features/organization/features/equipments/models'; +import { FacilityService } from '@features/organization/features/facilities/data-access'; +import { + InterventionOfflineService, + InterventionService, +} from '@features/organization/features/interventions/data-access'; +import type { + InterventionEquipmentCatalogSnapshot, + InterventionOutput, +} from '@features/organization/features/interventions/models'; +import { InterventionEquipmentCatalogService } from '@features/organization/features/interventions/services/intervention-equipment-catalog'; +import { InterventionInventoryService } from '@features/organization/features/interventions/services/intervention-inventory'; +import { ORGANIZATION_CONTEXT_PORT } from '@features/organization/ports'; +import { InterventionWorkspaceStore } from '../intervention-workspace.store'; + +describe('InterventionWorkspaceStore', () => { + const owner = signal('account'); + const organization = signal('org-1'); + const allowed = signal(true); + const visibleTypes = signal([]); + const type: EquipmentTypeOutput = { + '@id': '/api/organizations/org-1/equipment-types/foam_station', + '@type': 'EquipmentType', + value: 'foam_station', + label: 'Archived foam station', + archived: true, + family: 'fire', + revision: 2, + }; + const equipmentCatalog: InterventionEquipmentCatalogSnapshot = { + version: 1, + accountId: 'account', + organizationId: 'org-1', + entries: [type], + capturedAt: '2026-10-06T10:00:00Z', + }; + const intervention = { + id: 'intervention', + organization: '/api/organizations/org-1', + } as InterventionOutput; + const saved = { intervention, workItems: [], changes: [], issues: [], equipmentCatalog }; + const offline = { + publicationOwner: () => owner(), + getWorkspace: vi.fn(), + listOutbox: vi.fn(), + saveWorkspace: vi.fn(), + saveEquipmentCatalog: vi.fn(), + }; + const catalog = { listAll: vi.fn() }; + const target = { seed: vi.fn(), clear: vi.fn() }; + + beforeEach(() => { + vi.resetAllMocks(); + owner.set('account'); + organization.set('org-1'); + allowed.set(true); + visibleTypes.set([]); + offline.getWorkspace.mockResolvedValue(saved); + offline.listOutbox.mockResolvedValue([]); + target.seed.mockImplementation((_org: string, entries: readonly EquipmentTypeOutput[]) => + visibleTypes.set(entries), + ); + target.clear.mockImplementation(() => visibleTypes.set([])); + TestBed.configureTestingModule({ + providers: [ + InterventionWorkspaceStore, + { provide: InterventionInventoryService, useValue: { capture: () => of(null) } }, + InterventionEquipmentCatalogService, + { provide: EquipmentTypeCatalogStore, useValue: target }, + { provide: EquipmentTypeService, useValue: catalog }, + { provide: InterventionOfflineService, useValue: offline }, + { + provide: InterventionService, + useValue: { + get: vi.fn().mockReturnValue(throwError(() => new HttpErrorResponse({ status: 0 }))), + listAllWorkItems: () => of([]), + listAllChanges: () => of([]), + listIssues: () => of({ member: [] }), + }, + }, + { provide: FacilityService, useValue: {} }, + { provide: ConnectivityService, useValue: { isNetworkFailure: () => true } }, + { + provide: AUTH_SESSION_PORT, + useValue: { isAuthenticated: signal(true), sessionRevision: signal(1) }, + }, + { provide: ORGANIZATION_CONTEXT_PORT, useValue: { selectedOrganizationId: organization } }, + { + provide: OrganizationPermissionService, + useValue: { + isLoadingPermissions: signal(false), + permissionError: () => null, + hasPermission: () => allowed(), + }, + }, + ], + }); + }); + + it('restores the offline catalog and clears visible labels reactively on permission revocation and account changes', async () => { + const store = TestBed.inject(InterventionWorkspaceStore); + store.load('intervention'); + await vi.waitFor(() => expect(store.servedFromLocalCache()).toBe(true)); + TestBed.inject(ApplicationRef).tick(); + expect(visibleTypes()).toEqual([type]); + expect(catalog.listAll).not.toHaveBeenCalled(); + expect(offline.saveWorkspace).not.toHaveBeenCalled(); + allowed.set(false); + TestBed.inject(ApplicationRef).tick(); + expect(visibleTypes()).toEqual([]); + allowed.set(true); + TestBed.inject(ApplicationRef).tick(); + expect(visibleTypes()).toEqual([type]); + owner.set('other-account'); + TestBed.inject(ApplicationRef).tick(); + expect(visibleTypes()).toEqual([]); + }); + + it('keeps legacy snapshots compatible and clears a catalog when the organization context changes', async () => { + const store = TestBed.inject(InterventionWorkspaceStore); + store.load('intervention'); + await vi.waitFor(() => expect(store.servedFromLocalCache()).toBe(true)); + TestBed.inject(ApplicationRef).tick(); + expect(visibleTypes()).toEqual([type]); + organization.set('org-2'); + TestBed.inject(ApplicationRef).tick(); + expect(visibleTypes()).toEqual([]); + organization.set('org-1'); + offline.getWorkspace.mockResolvedValue({ ...saved, equipmentCatalog: undefined }); + store.reload('intervention'); + await vi.waitFor(() => expect(store.equipmentCatalogSnapshot()).toBeNull()); + TestBed.inject(ApplicationRef).tick(); + expect(visibleTypes()).toEqual([]); + expect(store.workItems()).toEqual([]); + }); +}); diff --git a/src/app/features/organization/features/interventions/state/intervention-workspace/testing/intervention-workspace.store.spec.ts b/src/app/features/organization/features/interventions/state/intervention-workspace/testing/intervention-workspace.store.spec.ts index 66efc59c9..3f55212eb 100644 --- a/src/app/features/organization/features/interventions/state/intervention-workspace/testing/intervention-workspace.store.spec.ts +++ b/src/app/features/organization/features/interventions/state/intervention-workspace/testing/intervention-workspace.store.spec.ts @@ -15,7 +15,10 @@ import type { InterventionIssueOutput, InterventionOutput, InterventionWorkItemOutput, + InterventionWorkItemExecutionResultInput, } from '@features/organization/features/interventions/models'; +import { InterventionEquipmentCatalogService } from '@features/organization/features/interventions/services/intervention-equipment-catalog'; +import { InterventionInventoryService } from '@features/organization/features/interventions/services/intervention-inventory'; import { InterventionWorkspaceStore } from '../intervention-workspace.store'; const intervention = { @@ -62,6 +65,12 @@ const proposedChange = { updatedAt: '2026-06-12T08:00:00.000Z', } as InterventionChangeOutput; +beforeEach(() => + TestBed.configureTestingModule({ + providers: [{ provide: InterventionInventoryService, useValue: { capture: () => of(null) } }], + }), +); + describe('InterventionWorkspaceStore offline field work', () => { let store: InstanceType; let mockService: { @@ -112,6 +121,14 @@ describe('InterventionWorkspaceStore offline field work', () => { TestBed.configureTestingModule({ providers: [ InterventionWorkspaceStore, + { + provide: InterventionEquipmentCatalogService, + useValue: { + accountId: () => 'account', + capture: vi.fn().mockReturnValue(of(null)), + restore: vi.fn(), + }, + }, { provide: InterventionService, useValue: mockService }, { provide: InterventionOfflineService, useValue: mockOffline }, { provide: FacilityService, useValue: { createForIntervention: vi.fn() } }, @@ -484,6 +501,14 @@ describe('InterventionWorkspaceStore activity timeline', () => { TestBed.configureTestingModule({ providers: [ InterventionWorkspaceStore, + { + provide: InterventionEquipmentCatalogService, + useValue: { + accountId: () => 'account', + capture: vi.fn().mockReturnValue(of(null)), + restore: vi.fn(), + }, + }, { provide: InterventionService, useValue: mockService }, { provide: InterventionOfflineService, useValue: mockOffline }, { provide: Dispatcher, useValue: { dispatch } }, @@ -857,6 +882,14 @@ describe('InterventionWorkspaceStore call state', () => { TestBed.configureTestingModule({ providers: [ InterventionWorkspaceStore, + { + provide: InterventionEquipmentCatalogService, + useValue: { + accountId: () => 'account', + capture: vi.fn().mockReturnValue(of(null)), + restore: vi.fn(), + }, + }, { provide: InterventionService, useValue: mockService }, { provide: FacilityService, useValue: mockFacilityService }, { @@ -1460,6 +1493,14 @@ describe('InterventionWorkspaceStore evidence upload', () => { TestBed.configureTestingModule({ providers: [ InterventionWorkspaceStore, + { + provide: InterventionEquipmentCatalogService, + useValue: { + accountId: () => 'account', + capture: vi.fn().mockReturnValue(of(null)), + restore: vi.fn(), + }, + }, { provide: InterventionService, useValue: mockService }, { provide: InterventionOfflineService, @@ -1659,6 +1700,14 @@ describe('InterventionWorkspaceStore offline attachment queue', () => { TestBed.configureTestingModule({ providers: [ InterventionWorkspaceStore, + { + provide: InterventionEquipmentCatalogService, + useValue: { + accountId: () => 'account', + capture: vi.fn().mockReturnValue(of(null)), + restore: vi.fn(), + }, + }, { provide: InterventionService, useValue: mockService }, { provide: InterventionOfflineService, useValue: mockOffline }, { provide: FacilityService, useValue: { createForIntervention: vi.fn() } }, @@ -1877,6 +1926,14 @@ describe('InterventionWorkspaceStore', () => { TestBed.configureTestingModule({ providers: [ InterventionWorkspaceStore, + { + provide: InterventionEquipmentCatalogService, + useValue: { + accountId: () => 'account', + capture: vi.fn().mockReturnValue(of(null)), + restore: vi.fn(), + }, + }, { provide: InterventionService, useValue: service }, { provide: FacilityService, useValue: { createForIntervention: vi.fn() } }, { provide: InterventionOfflineService, useValue: offline }, @@ -1986,6 +2043,88 @@ describe('InterventionWorkspaceStore', () => { expect(store.pendingWorkItemIds().size).toBe(0); }); + it.each(['offline', 'network-failure'] as const)( + 'durably stages the execution fact after %s with the operator instant and captured revision', + async (mode) => { + const item: InterventionWorkItemOutput = { + ...workItem, + action: 'maintenance', + operationId: 'maintenance-operation', + occurrenceId: 'maintenance-occurrence', + operationKind: 'maintenance', + }; + const executionResult: InterventionWorkItemExecutionResultInput = { + equipmentId: 'equipment-1', + performedAt: '2026-10-05T16:35:00+02:00', + outcome: 'performed', + workPerformed: 'Cleaned and adjusted the valve.', + }; + if (mode === 'offline') connectivity.isOffline.mockReturnValue(true); + else + service.updateWorkItem.mockReturnValueOnce( + throwError(() => new HttpErrorResponse({ status: 0 })), + ); + store.updateWorkItem({ + interventionId: intervention.id, + item, + input: { status: 'completed', executionResult }, + }); + await vi.waitFor(() => expect(store.workItemWriteCallState().status).toBe('success')); + + expect(offline.queue).toHaveBeenCalledExactlyOnceWith(intervention.id, 'work-item.update', { + workItemId: item.id, + revision: 1, + status: 'completed', + executionResult, + }); + const result = store.workItems()[0]?.executionResult; + expect(result).toEqual({ + ...executionResult, + operationId: 'maintenance-operation', + occurrenceId: 'maintenance-occurrence', + state: 'staged', + validatedAt: null, + history: [], + }); + expect(result).not.toHaveProperty('authorId'); + expect(store.workItems()[0]?.revision).toBe(2); + expect(store.intervention()?.completedWorkItemsCount).toBe(1); + expect(store.pendingWorkItemIds().size).toBe(0); + expect(offline.saveWorkspace).toHaveBeenCalledWith( + expect.anything(), + [expect.objectContaining({ executionResult: result })], + [], + [], + [], + { replace: false }, + ); + }, + ); + + it('keeps the previous fact and progress when execution cannot be persisted on the device', async () => { + connectivity.isOffline.mockReturnValue(true); + offline.queue.mockRejectedValueOnce(new Error('Device storage full')); + store.updateWorkItem({ + interventionId: intervention.id, + item: workItem, + input: { + status: 'completed', + executionResult: { + equipmentId: 'equipment-1', + performedAt: '2026-10-05T16:35:00+02:00', + outcome: 'successful', + workPerformed: 'Replaced the valve.', + }, + }, + }); + await vi.waitFor(() => expect(store.workItemWriteCallState().status).toBe('error')); + expect(store.workItemErrors()[workItem.id]).toBe('Device storage full'); + expect(store.workItems()).toEqual([workItem]); + expect(store.intervention()?.completedWorkItemsCount).toBe(0); + expect(store.pendingWorkItemIds().size).toBe(0); + expect(offline.saveWorkspace).not.toHaveBeenCalled(); + }); + it.each(['organization', 'account'] as const)( 'does not apply a late task write after the %s context changes', async (scope) => { diff --git a/src/app/features/organization/features/interventions/ui/components/intervention-inventory-context/index.ts b/src/app/features/organization/features/interventions/ui/components/intervention-inventory-context/index.ts new file mode 100644 index 000000000..cdf9da810 --- /dev/null +++ b/src/app/features/organization/features/interventions/ui/components/intervention-inventory-context/index.ts @@ -0,0 +1 @@ +export { InterventionInventoryContext } from './intervention-inventory-context.component'; diff --git a/src/app/features/organization/features/interventions/ui/components/intervention-inventory-context/intervention-inventory-context.component.html b/src/app/features/organization/features/interventions/ui/components/intervention-inventory-context/intervention-inventory-context.component.html new file mode 100644 index 000000000..f6d2a08f1 --- /dev/null +++ b/src/app/features/organization/features/interventions/ui/components/intervention-inventory-context/intervention-inventory-context.component.html @@ -0,0 +1,43 @@ +

+ + + + + + + @if (canSelectIntervention()) { + Whole intervention + } + @for (item of items(); track item.id) { + + + {{ labelOf(item.id) }} + + } + + +

+ Selecting a task links the parts used to that work and its equipment. +

+
diff --git a/src/app/features/organization/features/interventions/ui/components/intervention-inventory-context/intervention-inventory-context.component.ts b/src/app/features/organization/features/interventions/ui/components/intervention-inventory-context/intervention-inventory-context.component.ts new file mode 100644 index 000000000..ea6f2a4bf --- /dev/null +++ b/src/app/features/organization/features/interventions/ui/components/intervention-inventory-context/intervention-inventory-context.component.ts @@ -0,0 +1,148 @@ +import { + ChangeDetectionStrategy, + Component, + input, + output, + type InputSignal, + type OutputEmitterRef, +} from '@angular/core'; +import type { InterventionWorkItemOutput } from '@features/organization/features/interventions/models'; +import { HlmFieldImports } from '@shared/ui/field'; +import { HlmSelectImports } from '@shared/ui/select'; +import { InterventionTag } from '../intervention-tag'; + +/** + * Component InterventionInventoryContext + * @class InterventionInventoryContext + * + * @description + * Selects a task context from the caller's complete authorized workspace, including offline. + */ +@Component({ + selector: 'app-intervention-inventory-context', + templateUrl: './intervention-inventory-context.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [...HlmFieldImports, ...HlmSelectImports, InterventionTag], +}) +export class InterventionInventoryContext { + //#region Properties + /** + * Property items + * @readonly + * + * @description + * Tasks whose execution assignment permits a physical declaration. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly items: InputSignal = input< + readonly InterventionWorkItemOutput[] + >([]); + + /** + * Property workItemId + * @readonly + * + * @description + * Null means the intervention as a whole. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly workItemId: InputSignal = input(null); + + /** + * Property disabled + * @readonly + * + * @description + * The parent freezes context until a submitted declaration is durable. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly disabled: InputSignal = input(false); + + /** + * Property canSelectIntervention + * @readonly + * + * @description + * The intervention itself can be selected only by its responsible member or participants. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly canSelectIntervention: InputSignal = input(false); + + /** + * Property contextChanged + * @readonly + * + * @description + * Emits only a context from the provided authorized options. + * + * @access public + * @since unreleased + * + * @type {OutputEmitterRef} + */ + public readonly contextChanged: OutputEmitterRef = output(); + + /** + * Property labelOf + * @readonly + * + * @description + * Provides the same accessible label to the select trigger and every option. + * + * @access protected + * @since unreleased + * + * @type {(value: unknown) => string} + */ + protected readonly labelOf: (value: unknown) => string = (value) => { + if (value === 'general') + return $localize`:@@intervention.inventory.generalContext:Whole intervention`; + const index = this.items().findIndex((item) => item.id === value); + const item = this.items()[index]; + return ( + item?.targetSummary?.label || + (item ? $localize`:@@intervention.inventory.taskNumber:Work item ${index + 1}:number:` : '') + ); + }; + //#endregion + + //#region Methods + /** + * Method select + * @method select + * + * @description + * A selection never changes an already submitted physical declaration. + * + * @access protected + * @since unreleased + * + * @param {unknown} value - Selected option identity; only authorized task or general contexts are + * emitted. + * + * @returns {void} Retains the frozen declaration context or emits an authorized selection. + */ + protected select(value: unknown): void { + if (this.disabled()) return; + if (value === 'general' && this.canSelectIntervention()) this.contextChanged.emit(null); + else if (typeof value === 'string' && this.items().some((item) => item.id === value)) + this.contextChanged.emit(value); + } + //#endregion +} diff --git a/src/app/features/organization/features/interventions/ui/dialogs/intervention-execution-result-dialog/index.ts b/src/app/features/organization/features/interventions/ui/dialogs/intervention-execution-result-dialog/index.ts new file mode 100644 index 000000000..718d1a3a5 --- /dev/null +++ b/src/app/features/organization/features/interventions/ui/dialogs/intervention-execution-result-dialog/index.ts @@ -0,0 +1 @@ +export { InterventionExecutionResultDialog } from './intervention-execution-result-dialog.component'; diff --git a/src/app/features/organization/features/interventions/ui/dialogs/intervention-execution-result-dialog/intervention-execution-result-dialog.component.html b/src/app/features/organization/features/interventions/ui/dialogs/intervention-execution-result-dialog/intervention-execution-result-dialog.component.html new file mode 100644 index 000000000..a28c34f52 --- /dev/null +++ b/src/app/features/organization/features/interventions/ui/dialogs/intervention-execution-result-dialog/intervention-execution-result-dialog.component.html @@ -0,0 +1,35 @@ + + + +

Record actual work

+

{{ item().targetSummary?.label ?? item().target }}

+
+ +
+
+ diff --git a/src/app/features/organization/features/interventions/ui/dialogs/intervention-execution-result-dialog/intervention-execution-result-dialog.component.ts b/src/app/features/organization/features/interventions/ui/dialogs/intervention-execution-result-dialog/intervention-execution-result-dialog.component.ts new file mode 100644 index 000000000..07f44a1cf --- /dev/null +++ b/src/app/features/organization/features/interventions/ui/dialogs/intervention-execution-result-dialog/intervention-execution-result-dialog.component.ts @@ -0,0 +1,252 @@ +import { + ChangeDetectionStrategy, + Component, + input, + output, + signal, + viewChild, +} from '@angular/core'; +import type { BrnDialogState } from '@spartan-ng/brain/dialog'; +import type { EquipmentOutput } from '@features/organization/features/equipments/models'; +import type { + InterventionWorkItemExecutionResultInput, + InterventionWorkItemOutput, +} from '@features/organization/features/interventions/models'; +import { HlmDialog, HlmDialogImports } from '@shared/ui/dialog'; +import { UnsavedChangesDialog } from '@shared/unsaved-changes'; +import { InterventionExecutionResultForm } from '../../forms/intervention-execution-result-form'; + +/** + * Component InterventionExecutionResultDialog + * @class InterventionExecutionResultDialog + * + * @description + * Hosts equipment work results and guards uncommitted operator input on dismissal. + */ +@Component({ + selector: 'app-intervention-execution-result-dialog', + imports: [...HlmDialogImports, InterventionExecutionResultForm, UnsavedChangesDialog], + templateUrl: './intervention-execution-result-dialog.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class InterventionExecutionResultDialog { + //#region Properties + /** + * Property item + * @readonly + * + * @description + * Captured equipment work item for this dialog lifetime. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly item = input.required(); + + /** + * Property pending + * @readonly + * + * @description + * Prevents dismissal during accepted persistence. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly pending = input(false); + + /** + * Property disabled + * @readonly + * + * @description + * Server capabilities no longer allow committing this preserved result draft. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly disabled = input(false); + + /** + * Property serverError + * @readonly + * + * @description + * Result write failure kept beside the preserved form. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly serverError = input(null); + + /** + * Property replacementSuccessor + * @readonly + * + * @description + * Confirmed published successor of the task's original equipment. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly replacementSuccessor = input(null); + + /** + * Property replacementLoading + * @readonly + * + * @description + * Current proof read is still pending. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly replacementLoading = input(false); + + /** + * Property replacementReadFailed + * @readonly + * + * @description + * Current scope could not retrieve replacement proof. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly replacementReadFailed = input(false); + + /** + * Property originalEquipmentLink + * @readonly + * + * @description + * Equipment dossier route keeps the current work draft open in this tab. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly originalEquipmentLink = input(null); + + /** + * Property refreshReplacement + * @readonly + * + * @description + * Refreshes the linked successor without clearing the form. + * + * @access public + * @since unreleased + * + * @type {OutputEmitterRef} + */ + public readonly refreshReplacement = output(); + + /** + * Property submitted + * @readonly + * + * @description + * Validated result forwarded to the owning page. + * + * @access public + * @since unreleased + * + * @type {OutputEmitterRef} + */ + public readonly submitted = output(); + + /** + * Property dismissed + * @readonly + * + * @description + * Operator approved discarding any uncommitted input. + * + * @access public + * @since unreleased + * + * @type {OutputEmitterRef} + */ + public readonly dismissed = output(); + + /** + * Property dirty + * @readonly + * + * @description + * Form-owned dirtiness guards accidental closure. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal} + */ + protected readonly dirty = signal(false); + + /** + * Property discardState + * @readonly + * + * @description + * Explicit discard confirmation state. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal} + */ + protected readonly discardState = signal('closed'); + + /** + * Property dialog + * @readonly + * + * @description + * Restores the native panel when a dirty draft intercepted its closure. + * + * @access private + * @since unreleased + * + * @type {Signal} + */ + private readonly dialog = viewChild(HlmDialog); + //#endregion + + //#region Methods + /** + * Method requestClose + * @method requestClose + * + * @description + * Confirms discard rather than losing actual work details. + * + * @access protected + * @since unreleased + * + * @returns {void} + */ + protected requestClose(): void { + if (this.pending()) return; + if (this.dirty()) { + this.dialog()?.open(); + this.discardState.set('open'); + } else this.dismissed.emit(); + } + //#endregion +} diff --git a/src/app/features/organization/features/interventions/ui/forms/intervention-create-form/intervention-create-form.component.ts b/src/app/features/organization/features/interventions/ui/forms/intervention-create-form/intervention-create-form.component.ts index 93e212918..0e7d4c0e5 100644 --- a/src/app/features/organization/features/interventions/ui/forms/intervention-create-form/intervention-create-form.component.ts +++ b/src/app/features/organization/features/interventions/ui/forms/intervention-create-form/intervention-create-form.component.ts @@ -77,6 +77,8 @@ const TYPE_VALUES: ReadonlyArray = [ 'site_setup', 'inventory', 'inspection_campaign', + 'preventive_maintenance', + 'corrective_maintenance', ]; /** diff --git a/src/app/features/organization/features/interventions/ui/forms/intervention-execution-result-form/index.ts b/src/app/features/organization/features/interventions/ui/forms/intervention-execution-result-form/index.ts new file mode 100644 index 000000000..626b6911a --- /dev/null +++ b/src/app/features/organization/features/interventions/ui/forms/intervention-execution-result-form/index.ts @@ -0,0 +1 @@ +export { InterventionExecutionResultForm } from './intervention-execution-result-form.component'; diff --git a/src/app/features/organization/features/interventions/ui/forms/intervention-execution-result-form/intervention-execution-result-form.component.html b/src/app/features/organization/features/interventions/ui/forms/intervention-execution-result-form/intervention-execution-result-form.component.html new file mode 100644 index 000000000..52585caf8 --- /dev/null +++ b/src/app/features/organization/features/interventions/ui/forms/intervention-execution-result-form/intervention-execution-result-form.component.html @@ -0,0 +1,179 @@ +
+
+ @if (item().action === 'replacement') { +
+

+ Replacement equipment +

+ @if (replacementLoading()) { +

+ Checking the equipment replacement… +

+ } @else if (replacementSuccessor(); as successor) { +

+ {{ successor.assetCode ?? successor.name ?? successor.id }} +

+

+ This published equipment is linked to the retired original. +

+ } @else if (replacementReadFailed()) { +

+ The replacement could not be checked. Keep your draft and refresh when the equipment + dossier is available. +

+ } @else { +

+ Confirm the replacement in the equipment dossier, then refresh its successor. +

+ } +
+ @if (originalEquipmentLink(); as link) { + Open equipment dossier + } + +
+
+ } + @if (disabled()) { +

+ This task can no longer be executed in the current workflow. Your draft remains available. +

+ } + @if (serverMessages().length > 0) { +
+
+ } +
+ + +

+ Date and time in {{ timezone() }}. +

+ @if (resultForm.performedAt().touched() && resultForm.performedAt().invalid()) { + {{ resultForm.performedAt().errors()[0].message }} + } +
+
+ + + + @if (resultForm.outcome().value(); as value) { + {{ outcomeLabelOf(value) }} + } @else { + + } + + + Successful + @if (item().action === 'maintenance') { + Performed + } + Unsuccessful — work still required + + + @if (resultForm.outcome().touched() && resultForm.outcome().invalid()) { + {{ resultForm.outcome().errors()[0].message }} + } +
+
+ + + @if (resultForm.workPerformed().touched() && resultForm.workPerformed().invalid()) { + {{ resultForm.workPerformed().errors()[0].message }} + } +
+

+ The result is recorded now and validated when the intervention is published. An unsuccessful + result keeps the task open. +

+
+ + + + +
diff --git a/src/app/features/organization/features/interventions/ui/forms/intervention-execution-result-form/intervention-execution-result-form.component.ts b/src/app/features/organization/features/interventions/ui/forms/intervention-execution-result-form/intervention-execution-result-form.component.ts new file mode 100644 index 000000000..9d5107f7a --- /dev/null +++ b/src/app/features/organization/features/interventions/ui/forms/intervention-execution-result-form/intervention-execution-result-form.component.ts @@ -0,0 +1,453 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + inject, + input, + output, + signal, + untracked, +} from '@angular/core'; +import { + disabled, + form, + FormField, + FormRoot, + maxLength, + required, + validate, +} from '@angular/forms/signals'; +import { RouterLink } from '@angular/router'; +import { NgIcon, provideIcons } from '@ng-icons/core'; +import { lucideCircleAlert } from '@ng-icons/lucide'; +import { DateTime } from 'luxon'; +import type { EquipmentOutput } from '@features/organization/features/equipments/models'; +import type { + InterventionWorkItemExecutionResultInput, + InterventionWorkItemOutput, +} from '@features/organization/features/interventions/models'; +import { REGIONAL_FORMATTING_PORT } from '@features/organization/ports'; +import { serverMessagesOf } from '@shared/form-feedback'; +import { HlmAlertImports } from '@shared/ui/alert'; +import { HlmButton } from '@shared/ui/button'; +import { HlmDialogFooter } from '@shared/ui/dialog'; +import { HlmFieldImports } from '@shared/ui/field'; +import { HlmInput } from '@shared/ui/input'; +import { HlmSelectImports } from '@shared/ui/select'; +import { HlmSpinner } from '@shared/ui/spinner'; +import { HlmTextareaImports } from '@shared/ui/textarea'; +import type { InterventionExecutionResultFormValues } from './models/intervention-execution-result-form-values.interface'; + +/** + * Component InterventionExecutionResultForm + * @class InterventionExecutionResultForm + * + * @description + * Records actual maintenance work without deriving its date from task completion. + * A rejected write preserves the operator's draft. + */ +@Component({ + selector: 'app-intervention-execution-result-form', + imports: [ + NgIcon, + FormField, + FormRoot, + RouterLink, + HlmButton, + HlmDialogFooter, + HlmInput, + HlmSpinner, + ...HlmAlertImports, + ...HlmFieldImports, + ...HlmSelectImports, + ...HlmTextareaImports, + ], + templateUrl: './intervention-execution-result-form.component.html', + providers: [provideIcons({ lucideCircleAlert })], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class InterventionExecutionResultForm { + //#region Properties + /** + * Property item + * @readonly + * + * @description + * Captured equipment work item; its revision stays owned by the page. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly item = input.required(); + + /** + * Property pending + * @readonly + * + * @description + * Locks commitment while this result is being persisted. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly pending = input(false); + + /** + * Property disabled + * @readonly + * + * @description + * Keeps a preserved draft readable when the workflow no longer allows execution. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly disabled = input(false); + + /** + * Property serverError + * @readonly + * + * @description + * Rejected write feedback, displayed without clearing the draft. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly serverError = input(null); + + /** + * Property replacementSuccessor + * @readonly + * + * @description + * Equipment-owned successor confirmed by the current scoped read. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly replacementSuccessor = input(null); + + /** + * Property replacementLoading + * @readonly + * + * @description + * A proof refresh is pending; stale proof cannot complete a replacement. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly replacementLoading = input(false); + + /** + * Property replacementReadFailed + * @readonly + * + * @description + * The original or successor could not be read in the current scope. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly replacementReadFailed = input(false); + + /** + * Property originalEquipmentLink + * @readonly + * + * @description + * Opens the existing equipment dossier while preserving this attempt's draft. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly originalEquipmentLink = input(null); + + /** + * Property refreshReplacement + * @readonly + * + * @description + * Requests a fresh Equipment-owned replacement proof without resetting entered facts. + * + * @access public + * @since unreleased + * + * @type {OutputEmitterRef} + */ + public readonly refreshReplacement = output(); + + /** + * Property submitted + * @readonly + * + * @description + * Validated execution result with an explicit offset-bearing instant. + * + * @access public + * @since unreleased + * + * @type {OutputEmitterRef} + */ + public readonly submitted = output(); + + /** + * Property cancelled + * @readonly + * + * @description + * Requests dismissal through the host's draft guard. + * + * @access public + * @since unreleased + * + * @type {OutputEmitterRef} + */ + public readonly cancelled = output(); + + /** + * Property dirtyChanged + * @readonly + * + * @description + * Keeps dismissal aware of input that has not been committed. + * + * @access public + * @since unreleased + * + * @type {OutputEmitterRef} + */ + public readonly dirtyChanged = output(); + + /** + * Property regional + * @readonly + * + * @description + * Organization-owned timezone for operator-entered local times. + * + * @access private + * @since unreleased + * + * @type {RegionalFormattingPort} + */ + private readonly regional = inject(REGIONAL_FORMATTING_PORT); + + /** + * Property timezone + * @readonly + * + * @description + * Visible timezone of the performed-at input. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly timezone = computed(() => this.regional.regionalFormatting().timezone); + + /** + * Property model + * @readonly + * + * @description + * No timestamp is invented for previously unrecorded work. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal} + */ + protected readonly model = signal({ + performedAt: '', + outcome: '', + workPerformed: '', + }); + + /** + * Property outcomeLabelOf + * @readonly + * + * @description + * Resolves the selected result's visible label as well as its native select value. + * + * @access protected + * @since unreleased + * + * @type {(value: string) => string} + */ + protected readonly outcomeLabelOf = (value: string): string => { + switch (value) { + case 'successful': + return $localize`:@@intervention.execution.successful:Successful`; + case 'performed': + return $localize`:@@intervention.execution.performed:Performed`; + case 'failed': + return $localize`:@@intervention.execution.failed:Unsuccessful — work still required`; + default: + return ''; + } + }; + + /** + * Property resultForm + * @readonly + * + * @description + * Validates equipment-linked work, action-compatible outcome and an actual local datetime. + * + * @access protected + * @since unreleased + * + * @type {FieldTree} + */ + protected readonly resultForm = form(this.model, (path) => { + disabled(path, { when: () => this.pending() || this.disabled() }); + required(path.performedAt, { + message: $localize`:@@intervention.execution.dateRequired:Enter when the work was performed.`, + }); + validate(path.performedAt, ({ value }) => { + if (!value()) return null; + const instant = DateTime.fromISO(value(), { zone: this.timezone() }); + return instant.isValid && instant.toFormat("yyyy-MM-dd'T'HH:mm") === value() + ? null + : { + kind: 'datetime', + message: $localize`:@@intervention.execution.dateInvalid:Enter a valid date and time in the displayed timezone.`, + }; + }); + required(path.outcome, { + message: $localize`:@@intervention.execution.outcomeRequired:Choose the result of this work.`, + }); + validate(path.outcome, ({ value }) => + !value() || + ['successful', 'failed'].includes(value()) || + (this.item().action === 'maintenance' && value() === 'performed') + ? null + : { + kind: 'outcome', + message: $localize`:@@intervention.execution.outcomeInvalid:Choose a result appropriate for this action.`, + }, + ); + validate(path.outcome, ({ value }) => + this.item().action !== 'replacement' || + value() !== 'successful' || + (this.replacementSuccessor() !== null && !this.replacementLoading()) + ? null + : { + kind: 'replacement', + message: $localize`:@@intervention.execution.replacementRequired:Confirm the replacement in the equipment dossier, then refresh its successor.`, + }, + ); + validate(path.workPerformed, ({ value }) => + value().trim().length > 0 + ? null + : { + kind: 'required', + message: $localize`:@@intervention.execution.workRequired:Describe the work actually performed.`, + }, + ); + maxLength(path.workPerformed, 10000, { + message: $localize`:@@intervention.execution.workTooLong:Keep the work description within 10,000 characters.`, + }); + }); + + /** + * Property serverMessages + * @readonly + * + * @description + * Persistent feedback supports correcting and retrying a rejected result. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly serverMessages = computed(() => + serverMessagesOf( + this.serverError(), + [], + $localize`:@@intervention.execution.saveFailed:The execution result could not be saved.`, + ), + ); + //#endregion + + //#region Constructor + /** + * Constructor + * @constructor + * + * @description + * Starts a new attempt without reusing the previous attempt's time or facts. + * Publishes draft dirtiness to the host. + * + * @access public + * @since unreleased + */ + public constructor() { + effect(() => { + this.item(); + untracked(() => { + this.model.set({ performedAt: '', outcome: '', workPerformed: '' }); + this.resultForm().reset(); + }); + }); + effect(() => { + const dirty = this.resultForm().dirty(); + untracked(() => this.dirtyChanged.emit(dirty)); + }); + } + //#endregion + + //#region Methods + /** + * Method submit + * @method submit + * + * @description + * Converts the explicitly entered organization-local datetime into its API instant. + * + * @access protected + * @since unreleased + * + * @param {Event} event - Native form submission. + * + * @returns {void} + */ + protected submit(event: Event): void { + event.preventDefault(); + this.resultForm().markAsTouched(); + const equipmentId = /^\/api\/equipment\/([^/?#]+)$/.exec(this.item().target ?? '')?.[1]; + if (!equipmentId || this.resultForm().invalid() || this.pending() || this.disabled()) return; + const value = this.model(); + const performedAt = DateTime.fromISO(value.performedAt, { zone: this.timezone() }).toISO(); + if (!performedAt) return; + this.submitted.emit({ + equipmentId, + performedAt, + outcome: value.outcome as InterventionWorkItemExecutionResultInput['outcome'], + workPerformed: value.workPerformed.trim(), + }); + } + //#endregion +} diff --git a/src/app/features/organization/features/interventions/ui/forms/intervention-execution-result-form/models/intervention-execution-result-form-values.interface.ts b/src/app/features/organization/features/interventions/ui/forms/intervention-execution-result-form/models/intervention-execution-result-form-values.interface.ts new file mode 100644 index 000000000..df66f1cb4 --- /dev/null +++ b/src/app/features/organization/features/interventions/ui/forms/intervention-execution-result-form/models/intervention-execution-result-form-values.interface.ts @@ -0,0 +1,41 @@ +/** + * Interface InterventionExecutionResultFormValues + * @interface InterventionExecutionResultFormValues + * + * @description + * Operator-entered date and work details before conversion to the API result. + */ +export interface InterventionExecutionResultFormValues { + /** + * Property performedAt + * @readonly + * + * @description + * Local date and time in the organization's timezone. + * + * @type {string} + */ + readonly performedAt: string; + + /** + * Property outcome + * @readonly + * + * @description + * Explicit result selected by the operator. + * + * @type {string} + */ + readonly outcome: string; + + /** + * Property workPerformed + * @readonly + * + * @description + * Actual work performed, independent of planned instructions. + * + * @type {string} + */ + readonly workPerformed: string; +} diff --git a/src/app/features/organization/features/interventions/ui/forms/intervention-execution-result-form/testing/intervention-execution-result-form.component.spec.ts b/src/app/features/organization/features/interventions/ui/forms/intervention-execution-result-form/testing/intervention-execution-result-form.component.spec.ts new file mode 100644 index 000000000..69689973b --- /dev/null +++ b/src/app/features/organization/features/interventions/ui/forms/intervention-execution-result-form/testing/intervention-execution-result-form.component.spec.ts @@ -0,0 +1,225 @@ +import { provideZonelessChangeDetection, signal } from '@angular/core'; +import { TestBed, type ComponentFixture } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import type { EquipmentOutput } from '@features/organization/features/equipments/models'; +import type { + InterventionWorkItemExecutionResultInput, + InterventionWorkItemOutput, +} from '@features/organization/features/interventions/models'; +import { REGIONAL_FORMATTING_PORT } from '@features/organization/ports'; +import { DEFAULT_REGIONAL_FORMAT_SETTINGS } from '@shared/regional-format'; +import { InterventionExecutionResultForm } from '../intervention-execution-result-form.component'; + +const item: InterventionWorkItemOutput = { + '@id': '/api/intervention-work-items/work', + '@type': 'InterventionWorkItem', + id: 'work', + intervention: '/api/interventions/intervention', + action: 'repair', + target: '/api/equipment/00000000-0000-4000-8000-000000000001', + targetSummary: null, + resultResource: null, + assignee: null, + assigneeProfile: null, + source: 'planned', + status: 'in_progress', + required: true, + skipReason: null, + evidenceCount: 0, + revision: 7, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-10-06T00:00:00Z', +}; + +describe('InterventionExecutionResultForm', () => { + let fixture: ComponentFixture; + let submissions: InterventionWorkItemExecutionResultInput[]; + const submit = async (): Promise => { + (fixture.nativeElement as HTMLElement) + .querySelector('form') + ?.dispatchEvent(new Event('submit', { cancelable: true })); + await fixture.whenStable(); + }; + const fill = async (outcome = 'successful', performedAt = '2026-10-05T10:30'): Promise => { + const fields = fixture.componentInstance['resultForm']; + fields.performedAt().value.set(performedAt); + fields.outcome().value.set(outcome); + fields.workPerformed().value.set(' Replaced the pressure gauge. '); + await fixture.whenStable(); + }; + + beforeEach(async () => { + TestBed.configureTestingModule({ + providers: [ + provideZonelessChangeDetection(), + provideRouter([]), + { + provide: REGIONAL_FORMATTING_PORT, + useValue: { + regionalFormatting: signal({ + ...DEFAULT_REGIONAL_FORMAT_SETTINGS, + timezone: 'Europe/Paris', + }), + }, + }, + ], + }); + fixture = TestBed.createComponent(InterventionExecutionResultForm); + fixture.componentRef.setInput('item', item); + submissions = []; + fixture.componentInstance.submitted.subscribe((value) => submissions.push(value)); + await fixture.whenStable(); + }); + + it('requires an actual date and result without inferring them from task timestamps', async () => { + expect(fixture.componentInstance['model']().performedAt).toBe(''); + await submit(); + expect(submissions).toEqual([]); + expect((fixture.nativeElement as HTMLElement).textContent).toContain( + 'Enter when the work was performed.', + ); + }); + + it('emits the operator date in the organization timezone, independently of device timezone', async () => { + await fill(); + await submit(); + expect(submissions).toEqual([ + { + equipmentId: '00000000-0000-4000-8000-000000000001', + performedAt: '2026-10-05T10:30:00.000+02:00', + outcome: 'successful', + workPerformed: 'Replaced the pressure gauge.', + }, + ]); + }); + + it('rejects a nonexistent local time during the daylight-saving change', async () => { + await fill('successful', '2026-03-29T02:30'); + await submit(); + expect(submissions).toEqual([]); + expect(fixture.componentInstance['resultForm'].performedAt().invalid()).toBe(true); + }); + + it('records failed work and reserves the performed outcome for maintenance', async () => { + await fill('performed'); + await submit(); + expect(submissions).toEqual([]); + await fill('failed'); + await submit(); + expect(submissions[0].outcome).toBe('failed'); + fixture.componentRef.setInput('item', { ...item, action: 'maintenance' }); + await fixture.whenStable(); + await fill('performed'); + await submit(); + expect(submissions[1].outcome).toBe('performed'); + }); + + it.each([ + ['successful', 'Successful'], + ['performed', 'Performed'], + ['failed', 'Unsuccessful — work still required'], + ])('displays the human-readable selected %s result in the trigger', async (outcome, label) => { + fixture.componentRef.setInput('item', { ...item, action: 'maintenance' }); + await fixture.whenStable(); + await fill(outcome); + const trigger = (fixture.nativeElement as HTMLElement).querySelector( + '[data-testid="execution-outcome"]', + ); + expect(trigger?.textContent?.trim()).toBe(label); + expect((fixture.nativeElement as HTMLElement).querySelector('hlm-select-content')).toBeNull(); + }); + + it('preserves replacement facts while refreshing proof and only completes after a confirmed successor', async () => { + fixture.componentRef.setInput('item', { ...item, action: 'replacement' }); + fixture.componentRef.setInput('originalEquipmentLink', [ + '/organizations', + 'org-1', + 'equipments', + 'original', + ]); + await fixture.whenStable(); + await fill(); + await submit(); + expect(submissions).toEqual([]); + expect((fixture.nativeElement as HTMLElement).textContent).toContain( + 'Confirm the replacement in the equipment dossier', + ); + const facts = { ...fixture.componentInstance['model']() }; + fixture.componentRef.setInput('replacementLoading', true); + fixture.componentRef.setInput('replacementReadFailed', true); + await fixture.whenStable(); + await submit(); + expect(submissions).toEqual([]); + fixture.componentRef.setInput('replacementLoading', false); + fixture.componentRef.setInput('replacementReadFailed', false); + fixture.componentRef.setInput('replacementSuccessor', { + id: 'successor', + assetCode: 'EXT-NEW', + } as EquipmentOutput); + await fixture.whenStable(); + expect(fixture.componentInstance['model']()).toEqual(facts); + expect((fixture.nativeElement as HTMLElement).textContent).toContain('EXT-NEW'); + await submit(); + expect(submissions).toHaveLength(1); + }); + + it('retains a failed replacement as physical fact even when successor proof is unavailable', async () => { + fixture.componentRef.setInput('item', { ...item, action: 'replacement' }); + fixture.componentRef.setInput('replacementReadFailed', true); + await fixture.whenStable(); + await fill('failed'); + await submit(); + expect(submissions[0]?.outcome).toBe('failed'); + }); + + it('preserves submitted facts on a rejected write and prevents double commitment while pending', async () => { + await fill(); + fixture.componentRef.setInput('serverError', { message: 'The task revision changed.' }); + fixture.componentRef.setInput('pending', true); + await fixture.whenStable(); + await submit(); + expect(submissions).toEqual([]); + expect(fixture.componentInstance['model']().workPerformed).toBe( + ' Replaced the pressure gauge. ', + ); + fixture.componentRef.setInput('pending', false); + await fixture.whenStable(); + await submit(); + expect(submissions).toHaveLength(1); + }); + + it('requires new facts for a new attempt and preserves the draft when execution is no longer allowed', async () => { + fixture.componentRef.setInput('item', { + ...item, + executionResult: { + equipmentId: 'eq-1', + performedAt: '2026-02-01T10:00:00Z', + outcome: 'failed', + workPerformed: 'Previous failed attempt.', + state: 'staged', + validatedAt: null, + operationId: null, + occurrenceId: null, + }, + }); + await fixture.whenStable(); + expect(fixture.componentInstance['model']()).toEqual({ + performedAt: '', + outcome: '', + workPerformed: '', + }); + await fill(); + fixture.componentRef.setInput('disabled', true); + await fixture.whenStable(); + await submit(); + expect(submissions).toEqual([]); + expect(fixture.componentInstance['model']().workPerformed).toContain( + 'Replaced the pressure gauge.', + ); + expect( + (fixture.nativeElement as HTMLElement).querySelector( + '[data-testid="execution-submit"]', + )?.disabled, + ).toBe(true); + }); +}); diff --git a/src/app/features/organization/features/interventions/ui/forms/intervention-work-item-form/intervention-work-item-form.component.html b/src/app/features/organization/features/interventions/ui/forms/intervention-work-item-form/intervention-work-item-form.component.html index d841ea45c..19d8b5818 100644 --- a/src/app/features/organization/features/interventions/ui/forms/intervention-work-item-form/intervention-work-item-form.component.html +++ b/src/app/features/organization/features/interventions/ui/forms/intervention-work-item-form/intervention-work-item-form.component.html @@ -39,9 +39,12 @@ - + No target matches.
- @for (option of targetOptions(); track option.value) { + @for (option of availableTargets(); track option.value) { {{ option.label }} @@ -79,9 +81,18 @@ [state]="catalogues().equipment" (requested)="catalogueRequested.emit($event)" /> - - Leave empty for site-level work. - + @if (requiresEquipment()) { + + Select the equipment to keep its maintenance history traceable. + + } @else { + + Leave empty for site-level work. + + } + @if (workItemForm.target().touched() && workItemForm.target().invalid()) { + {{ workItemForm.target().errors()[0].message }} + } diff --git a/src/app/features/organization/features/interventions/ui/forms/intervention-work-item-form/intervention-work-item-form.component.ts b/src/app/features/organization/features/interventions/ui/forms/intervention-work-item-form/intervention-work-item-form.component.ts index 184374b43..1b88e3732 100644 --- a/src/app/features/organization/features/interventions/ui/forms/intervention-work-item-form/intervention-work-item-form.component.ts +++ b/src/app/features/organization/features/interventions/ui/forms/intervention-work-item-form/intervention-work-item-form.component.ts @@ -63,11 +63,19 @@ import type { InterventionWorkItemFormValues } from './models'; import type { InterventionWorkItemFormDraft } from './models/intervention-work-item-form-draft.model'; import { HlmItemImports } from '@shared/ui/item'; -/** The kinds of field work an item can record. */ +/** + * Constant ACTION_VALUES + * + * @description + * The kinds of field work an item can record. + */ const ACTION_VALUES: ReadonlyArray = [ 'site_setup', 'inventory', 'inspection', + 'maintenance', + 'repair', + 'replacement', ]; /** @@ -77,6 +85,7 @@ const ACTION_VALUES: ReadonlyArray = [ * A new task with unknown effort and no period override. * * @since 1.0.0 + * * @type {InterventionWorkItemFormDraft} */ const EMPTY_VALUES: InterventionWorkItemFormDraft = { @@ -94,11 +103,8 @@ const EMPTY_VALUES: InterventionWorkItemFormDraft = { * * @description * A task being added to an intervention's prepared scope. - * - * Only the action is required. A target and an assignee are genuinely optional - * — the backend accepts an item without either, and a planner often knows what - * kind of work is needed before knowing which equipment or which agent. - * + * Equipment maintenance, repair and replacement require an equipment target. + * Other preparation actions may remain untargeted; assignment and effort are optional. * Its own host fills the flex column its hosting sheet establishes: the field * group scrolls independently while the `hlm-sheet-footer` action row stays * pinned, without the sheet needing to know about the form's internal layout. @@ -137,6 +143,21 @@ const EMPTY_VALUES: InterventionWorkItemFormDraft = { changeDetection: ChangeDetectionStrategy.OnPush, }) export class InterventionWorkItemForm { + /** + * Property prefill + * @readonly + * + * @description + * Equipment preparation context confirmed through the normal task form. + * + * @access public + * @since unreleased + * + * @type {InputSignal | null>} + */ + public readonly prefill = input | null>( + null, + ); /** * Property locale * @readonly @@ -274,17 +295,27 @@ export class InterventionWorkItemForm { /** * Property catalogueSearched * @readonly - * @description Requests remote options without replacing the draft. + * + * @description + * Requests remote options without replacing the draft. + * * @access public * @since 1.0.0 + * + * @type {OutputEmitterRef} */ public readonly catalogueSearched = output(); /** * Property catalogues * @readonly - * @description Loaded coverage and failures by preparation source. + * + * @description + * Loaded coverage and failures by preparation source. + * * @access public * @since 1.0.0 + * + * @type {InputSignal>>} */ public readonly catalogues = input< Partial> @@ -292,9 +323,14 @@ export class InterventionWorkItemForm { /** * Property catalogueRequested * @readonly - * @description Requests another source page while preserving the form. + * + * @description + * Requests another source page while preserving the form. + * * @access public * @since 1.0.0 + * + * @type {OutputEmitterRef} */ public readonly catalogueRequested = output(); @@ -302,9 +338,13 @@ export class InterventionWorkItemForm { /** * Property pending * @readonly - * @description Whether the creation request is in flight. + * + * @description + * Whether the creation request is in flight. + * * @access public * @since 1.0.0 + * * @type {InputSignal} */ public readonly pending: InputSignal = input(false); @@ -312,9 +352,13 @@ export class InterventionWorkItemForm { /** * Property disabled * @readonly - * @description Whether the scope may still grow. + * + * @description + * Whether the scope may still grow. + * * @access public * @since 1.0.0 + * * @type {InputSignal} */ public readonly disabled: InputSignal = input(false); @@ -322,9 +366,13 @@ export class InterventionWorkItemForm { /** * Property serverError * @readonly - * @description Whatever the creation failed with. + * + * @description + * Whatever the creation failed with. + * * @access public * @since 1.0.0 + * * @type {InputSignal} */ public readonly serverError: InputSignal = input(null); @@ -332,9 +380,13 @@ export class InterventionWorkItemForm { /** * Property targetOptions * @readonly - * @description The facilities and equipment an item can point at. + * + * @description + * The facilities and equipment an item can point at. + * * @access public * @since 1.0.0 + * * @type {InputSignal} */ public readonly targetOptions: InputSignal = input< @@ -344,9 +396,13 @@ export class InterventionWorkItemForm { /** * Property memberOptions * @readonly - * @description The members an item can be assigned to. + * + * @description + * The members an item can be assigned to. + * * @access public * @since 1.0.0 + * * @type {InputSignal} */ public readonly memberOptions: InputSignal = input< @@ -358,9 +414,13 @@ export class InterventionWorkItemForm { /** * Property submitted * @readonly - * @description The validated item, with its optional fields trimmed. + * + * @description + * The validated item, with its optional fields trimmed. + * * @access public * @since 1.0.0 + * * @type {OutputEmitterRef} */ public readonly submitted: OutputEmitterRef = @@ -369,9 +429,13 @@ export class InterventionWorkItemForm { /** * Property cancelled * @readonly - * @description The planner backed out. + * + * @description + * The planner backed out. + * * @access public * @since 1.0.0 + * * @type {OutputEmitterRef} */ public readonly cancelled: OutputEmitterRef = output(); @@ -406,6 +470,13 @@ export class InterventionWorkItemForm { * @since 7.1.0 */ public constructor() { + effect(() => { + const prefill = this.prefill(); + untracked(() => { + this.model.set(prefill ? { ...EMPTY_VALUES, ...prefill } : EMPTY_VALUES); + this.workItemForm().reset(); + }); + }); effect((): void => { const dirty: boolean = this.workItemForm().dirty(); @@ -427,19 +498,79 @@ export class InterventionWorkItemForm { /** * Property actionValues * @readonly - * @description Exposed for the action select. + * + * @description + * Exposed for the action select. + * * @access protected * @since 1.0.0 + * * @type {ReadonlyArray} */ protected readonly actionValues: ReadonlyArray = ACTION_VALUES; + /** + * Property requiresEquipment + * @readonly + * + * @description + * Maintenance work records a result against one identified equipment. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly requiresEquipment: Signal = computed(() => + ['maintenance', 'repair', 'replacement'].includes(this.model().action), + ); + + /** + * Property targetPlaceholder + * @readonly + * + * @description + * Distinguishes mandatory equipment selection from optional site-level targeting. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly targetPlaceholder = computed(() => + this.requiresEquipment() + ? $localize`:@@intervention.wif.equipmentTargetPlaceholder:Select equipment` + : $localize`:@@intervention.wif.targetPlaceholder:Optional site, facility or equipment target`, + ); + + /** + * Property availableTargets + * @readonly + * + * @description + * Keeps facilities out of equipment-specific maintenance choices. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly availableTargets: Signal = computed(() => + this.requiresEquipment() + ? this.targetOptions().filter((option) => option.value.startsWith('/api/equipment/')) + : this.targetOptions(), + ); + /** * Property model * @readonly - * @description The drafted item. + * + * @description + * The drafted item. + * * @access protected * @since 1.0.0 + * * @type {WritableSignal} */ protected readonly model: WritableSignal = @@ -658,15 +789,28 @@ export class InterventionWorkItemForm { required(path.action, { message: $localize`:@@intervention.wif.actionRequired:Choose what kind of work this item records.`, }); + validate(path.target, ({ value }) => + !['maintenance', 'repair', 'replacement'].includes(this.model().action) || + /^\/api\/equipment\/[^/?#]+$/.test(value()) + ? null + : { + kind: 'equipment', + message: $localize`:@@intervention.wif.equipmentRequired:Choose the equipment this work concerns.`, + }, + ); }, ); /** * Property targetLabelOf * @readonly - * @description Names a target IRI for the combobox trigger. + * + * @description + * Names a target IRI for the combobox trigger. + * * @access protected * @since 1.0.0 + * * @type {(value: string) => string} */ protected readonly targetLabelOf: (value: string) => string = (value) => @@ -676,9 +820,13 @@ export class InterventionWorkItemForm { /** * Property memberLabelOf * @readonly - * @description Names a member IRI for the combobox trigger. + * + * @description + * Names a member IRI for the combobox trigger. + * * @access protected * @since 1.0.0 + * * @type {(value: string) => string} */ protected readonly memberLabelOf: (value: string) => string = (value) => @@ -688,9 +836,13 @@ export class InterventionWorkItemForm { /** * Property actionLabelOf * @readonly - * @description Names an action for the select's own value rendering. + * + * @description + * Names an action for the select's own value rendering. + * * @access protected * @since 1.0.0 + * * @type {(value: InterventionWorkItemAction) => string} */ protected readonly actionLabelOf: (value: InterventionWorkItemAction) => string = (value) => @@ -730,6 +882,7 @@ export class InterventionWorkItemForm { * @since 1.0.0 * * @param {string | null} value - Intervention bound, optionally including its offset. + * * @returns {Date | undefined} Calendar date without a browser-zone day shift. */ private toCalendarDate(value: string | null): Date | undefined { @@ -749,6 +902,7 @@ export class InterventionWorkItemForm { * @since 1.0.0 * * @param {BrnOverlayState} state - Native drawer state. + * * @returns {void} */ protected changeCalendarState(state: BrnOverlayState): void { diff --git a/src/app/features/organization/features/interventions/ui/forms/intervention-work-item-form/models/intervention-work-item-form-values.model.ts b/src/app/features/organization/features/interventions/ui/forms/intervention-work-item-form/models/intervention-work-item-form-values.model.ts index 56e7fb765..b64d95906 100644 --- a/src/app/features/organization/features/interventions/ui/forms/intervention-work-item-form/models/intervention-work-item-form-values.model.ts +++ b/src/app/features/organization/features/interventions/ui/forms/intervention-work-item-form/models/intervention-work-item-form-values.model.ts @@ -5,8 +5,8 @@ import type { InterventionWorkItemAction } from '@features/organization/features * @interface InterventionWorkItemFormValues * * @description - * Prepared-scope task draft. Only the action is required; target, assignee and - * estimate may remain unknown until field execution. + * Prepared-scope task draft. Maintenance actions require an identified equipment; + * assignee and estimate may remain unknown until field execution. * * @version 1.0.0 */ diff --git a/src/app/features/organization/features/interventions/ui/forms/intervention-work-item-form/testing/intervention-work-item-form.component.spec.ts b/src/app/features/organization/features/interventions/ui/forms/intervention-work-item-form/testing/intervention-work-item-form.component.spec.ts index 817342328..a42345b90 100644 --- a/src/app/features/organization/features/interventions/ui/forms/intervention-work-item-form/testing/intervention-work-item-form.component.spec.ts +++ b/src/app/features/organization/features/interventions/ui/forms/intervention-work-item-form/testing/intervention-work-item-form.component.spec.ts @@ -417,4 +417,28 @@ describe('InterventionWorkItemForm', () => { 'Unknown member', ); }); + + it('requires an equipment target for maintenance and excludes facilities from the picker', async () => { + fixture.componentRef.setInput('targetOptions', [ + { value: '/api/facilities/site', label: 'Main site' }, + { value: '/api/equipment/equipment-1', label: 'Extinguisher A-12' }, + ]); + const component = fixture.componentInstance; + component['workItemForm'].action().value.set('maintenance'); + await fixture.whenStable(); + expect(component['availableTargets']()).toEqual([ + { value: '/api/equipment/equipment-1', label: 'Extinguisher A-12' }, + ]); + await submit(); + expect(submissions).toEqual([]); + component['workItemForm'].target().value.set('/api/facilities/site'); + await submit(); + expect(submissions).toEqual([]); + component['workItemForm'].target().value.set('/api/equipment/equipment-1'); + await submit(); + expect(submissions[0]).toMatchObject({ + action: 'maintenance', + target: '/api/equipment/equipment-1', + }); + }); }); diff --git a/src/app/features/organization/features/interventions/ui/pages/intervention-detail-page/intervention-detail-page.component.html b/src/app/features/organization/features/interventions/ui/pages/intervention-detail-page/intervention-detail-page.component.html index b01f9c654..1cdd3d643 100644 --- a/src/app/features/organization/features/interventions/ui/pages/intervention-detail-page/intervention-detail-page.component.html +++ b/src/app/features/organization/features/interventions/ui/pages/intervention-detail-page/intervention-detail-page.component.html @@ -423,6 +423,162 @@
} + @if (canReadInventory()) { +
+
+

+ Parts used +

+ +
+ @if (inventoryPrepared()) { +
+ @if (inventoryStore.hasUnpersistedDeclaration()) { +
+

+ Declaration not yet saved on this device +

+

+ Keep this page open. Retry the same declaration before leaving or + publishing. +

+
+ } + @if (inventoryStore.readCallState().error; as error) { +
+

+ Stock preparation could not finish +

+

{{ error.message }}

+ +
+ } + @if ( + !inventoryStore.catalogReady() && + inventoryStore.readCallState().status !== 'pending' + ) { +

+ Connect and prepare this intervention’s parts and warehouses before + declaring them offline. +

+ } + @if (inventoryStore.fromDevice()) { +

+ References saved on this device. Stock availability is confirmed when + declarations reach the server. +

+ } + @if (inventoryStore.readCallState().status === 'pending') { +

+ Preparing stock references and declarations… +

+ } + @if (inventoryWorkItems().length > 0) { + + } + @if (intervention.status === 'published') { +

+ A declaration added after publication updates current stock records. The + published dossier keeps its original data. +

+ } + +
+ } +
+ } + @if (canReadMaintenanceCosts()) { + View internal maintenance costs + } +
} + + @if (store.intervention(); as intervention) {
More actions >

{{ target(operation) }}

-

- {{ preview(operation) }} -

+ @if (executionResultOf(operation); as result) { + + } @else { +

+ {{ preview(operation) }} +

+ } @if (operation.status === 'conflict') {

The resource changed on the server. Review your local content before reapplying it. @@ -137,7 +147,17 @@

@if (confirmedOperation(); as operation) { -

{{ preview(operation) }}

+ @if (executionResultOf(operation); as result) { + + } @else { +

{{ preview(operation) }}

+ } @if (confirmation()?.action === 'discard') {

This saved operation will be removed from this device without being sent. This cannot be @@ -172,6 +192,20 @@

+ +
+

+ Performed {{ result.performedAt | appOrgDate: 'datetime' : regionalFormatting() }} +

+

{{ executionOutcomeLabel(result) }}

+

{{ result.workPerformed }}

+ @if (resource) { +

+ Replacement equipment: {{ resource }} +

+ } +
+
@if (workloadReview(); as operation) { @if (operation.workloadAssessment; as assessment) { } + */ + public readonly regionalFormatting = input( + DEFAULT_REGIONAL_FORMAT_SETTINGS, + ); /** * Property visible * @readonly @@ -352,6 +375,73 @@ export class InterventionOperationsSheet { return this.labels[operation.type]; } + /** + * Method executionResultOf + * @method executionResultOf + * + * @description + * Exposes the local result facts that the operator must compare before reapplying a conflict. + * + * @access protected + * @since unreleased + * + * @param {InterventionOutboxOperation} operation - Queued operation. + * + * @returns {InterventionWorkItemExecutionResultInput | null} Recorded physical work, if present. + */ + protected executionResultOf( + operation: InterventionOutboxOperation, + ): InterventionWorkItemExecutionResultInput | null { + return operation.type === 'work-item.update' + ? (operation.payload.executionResult ?? null) + : null; + } + + /** + * Method replacementResourceOf + * @method replacementResourceOf + * + * @description + * Identifies the persisted successor during human review of a replacement conflict. + * + * @access protected + * @since unreleased + * + * @param {InterventionOutboxOperation} operation - Queued operation. + * + * @returns {string | null} Canonical result resource, when attached to the queued work. + */ + protected replacementResourceOf(operation: InterventionOutboxOperation): string | null { + return operation.type === 'work-item.update' + ? (operation.payload.resultResource ?? null) + : null; + } + + /** + * Method executionOutcomeLabel + * @method executionOutcomeLabel + * + * @description + * Names the actual local outcome independently of task completion status. + * + * @access protected + * @since unreleased + * + * @param {InterventionWorkItemExecutionResultInput} result - Queued physical result. + * + * @returns {string} Localized outcome. + */ + protected executionOutcomeLabel(result: InterventionWorkItemExecutionResultInput): string { + switch (result.outcome) { + case 'successful': + return $localize`:@@intervention.execution.successful:Successful`; + case 'failed': + return $localize`:@@intervention.execution.failed:Unsuccessful — work still required`; + case 'performed': + return $localize`:@@intervention.execution.performed:Performed`; + } + } + /** * Method statusPreviewLabel * @method statusPreviewLabel diff --git a/src/app/features/organization/features/interventions/ui/sheets/intervention-operations-sheet/testing/intervention-operations-sheet.component.spec.ts b/src/app/features/organization/features/interventions/ui/sheets/intervention-operations-sheet/testing/intervention-operations-sheet.component.spec.ts index 4d10c9689..9ace74f68 100644 --- a/src/app/features/organization/features/interventions/ui/sheets/intervention-operations-sheet/testing/intervention-operations-sheet.component.spec.ts +++ b/src/app/features/organization/features/interventions/ui/sheets/intervention-operations-sheet/testing/intervention-operations-sheet.component.spec.ts @@ -73,6 +73,73 @@ describe('InterventionOperationsSheet', () => { afterEach(() => fixture.destroy()); + it('shows physical result facts beside the current server values before reapplying a conflict', async () => { + fixture.componentRef.setInput('operations', [ + { + ...operation('execution-result', 'work-item.update', { + workItemId: 'task', + revision: 7, + status: 'in_progress', + executionResult: { + equipmentId: 'eq-1', + performedAt: '2026-03-01T11:00:00+01:00', + outcome: 'failed', + workPerformed: 'Local pressure test failed; follow-up is required.', + }, + }), + status: 'conflict', + baseRevision: 7, + serverRevision: 8, + serverValues: { workPerformed: 'Server valve replacement passed.', outcome: 'successful' }, + }, + ]); + await open(); + expect(row('execution-result')?.textContent).toContain( + 'Local pressure test failed; follow-up is required.', + ); + expect(row('execution-result')?.textContent).toContain('Unsuccessful — work still required'); + button(row('execution-result'), 'Review and retry')?.click(); + await fixture.whenStable(); + expect(document.body.textContent).toContain( + 'Local pressure test failed; follow-up is required.', + ); + expect(document.body.textContent).toContain('Server valve replacement passed.'); + expect(resolved).toEqual([]); + }); + + it('keeps the queued replacement successor visible when reviewing a conflicting result', async () => { + const resultResource = '/api/equipment/00000000-0000-4000-8000-000000000002'; + fixture.componentRef.setInput('operations', [ + { + ...operation('replacement', 'work-item.update', { + workItemId: 'task', + revision: 7, + status: 'completed', + resultResource, + executionResult: { + equipmentId: '00000000-0000-4000-8000-000000000001', + performedAt: '2026-03-01T11:00:00+01:00', + outcome: 'successful', + workPerformed: 'Installed the replacement extinguisher.', + }, + }), + status: 'conflict', + baseRevision: 7, + serverRevision: 8, + serverValues: { resultResource: '/api/equipment/00000000-0000-4000-8000-000000000003' }, + }, + ]); + await open(); + expect(row('replacement')?.textContent).toContain(`Replacement equipment: ${resultResource}`); + button(row('replacement'), 'Review and retry')?.click(); + await fixture.whenStable(); + expect(document.body.textContent).toContain(resultResource); + expect(document.body.textContent).toContain( + '/api/equipment/00000000-0000-4000-8000-000000000003', + ); + expect(resolved).toEqual([]); + }); + it('shows loading, a truthful empty state and a retryable local-read failure', async () => { expect(sheet()).toBeNull(); fixture.componentRef.setInput('callState', pendingCallState()); diff --git a/src/app/features/organization/features/interventions/ui/sheets/intervention-time-sheet/intervention-time-sheet.component.html b/src/app/features/organization/features/interventions/ui/sheets/intervention-time-sheet/intervention-time-sheet.component.html index 0b264ca8c..e9b6d5b3e 100644 --- a/src/app/features/organization/features/interventions/ui/sheets/intervention-time-sheet/intervention-time-sheet.component.html +++ b/src/app/features/organization/features/interventions/ui/sheets/intervention-time-sheet/intervention-time-sheet.component.html @@ -45,6 +45,29 @@

{{ writeState().error?.message }}

} + @if (draftEntryState().status === 'pending') { +
+ } @else if (draftEntryState().status === 'error') { +
+

{{ draftEntryState().error?.message }}

+ +
+ } @if (editing(); as value) { -

- No journal has been saved on this device. Reconnect to load its history. -

+ @if (page() === 1) { +

+ No journal has been saved on this device. Reconnect to load its history. +

+ } @else { +

+ This journal page has not been saved on this device. Reconnect to load it. +

+ } } @for (entry of entries(); track entry.id) { + @let history = historyStates()[entry.id];
{{ @@ -152,19 +182,44 @@

} -
+
+ @if (offline()) { +

+ Correction history is unavailable offline. Reconnect to load earlier versions. +

+ } @else if (entry.syncStatus) { +

+ Synchronize or review this pending change before loading correction history. +

+ }
    - @for (version of entry.versions; track version.revision) { + @for ( + version of history?.data?.versions ?? entry.versions; + track version.revision + ) {
  1. v{{ version.revision }} · @@ -180,6 +235,43 @@
  2. }
+ @if (history?.status === 'pending') { +
+ } + @if (history?.status === 'error' && !offline()) { + + } @else if (history?.data?.nextBeforeRevision && !offline()) { + + }
@if (canCorrect(entry)) { @@ -220,6 +312,48 @@

No time logged yet

} } + @if (page() > 1 || nextPage() !== null || totalItems() !== null) { + + } } } diff --git a/src/app/features/organization/features/interventions/ui/sheets/intervention-time-sheet/intervention-time-sheet.component.ts b/src/app/features/organization/features/interventions/ui/sheets/intervention-time-sheet/intervention-time-sheet.component.ts index a91529aa4..872a3e007 100644 --- a/src/app/features/organization/features/interventions/ui/sheets/intervention-time-sheet/intervention-time-sheet.component.ts +++ b/src/app/features/organization/features/interventions/ui/sheets/intervention-time-sheet/intervention-time-sheet.component.ts @@ -14,11 +14,12 @@ import { type Signal, type WritableSignal, } from '@angular/core'; -import { type CallState } from '@core/request-state'; +import { idleCallState, type CallState } from '@core/request-state'; import type { InterventionTimeDraft, InterventionTimeEntryView, InterventionTimeWrite, + InterventionTimeEntryVersionsOutput, InterventionWorkItemOutput, MemberSelectOption, } from '@features/organization/features/interventions/models'; @@ -41,7 +42,8 @@ import { InterventionTimeForm } from '../../forms/intervention-time-form'; * @class InterventionTimeSheet * * @description - * Contextual journal and explicit corrections, usable after publication. Draft persistence and transport remain owned by the page. + * Contextual journal and explicit corrections, usable after publication. Draft persistence and + * transport remain owned by the page. * * @version 1.0.0 */ @@ -135,11 +137,11 @@ export class InterventionTimeSheet { */ protected readonly draftServerEntry: Signal = computed(() => { const draft = this.draft(); - return draft?.baseRevision !== null - ? (this.entries().find( - (entry) => - entry.id === draft?.id && !entry.syncStatus && entry.revision !== draft.baseRevision, - ) ?? null) + if (!draft || draft.baseRevision === null) return null; + const latest = + this.draftEntryState().data ?? this.entries().find((entry) => entry.id === draft.id); + return latest?.id === draft.id && !latest.syncStatus && latest.revision !== draft.baseRevision + ? latest : null; }); @@ -303,6 +305,135 @@ export class InterventionTimeSheet { */ public readonly historyUnavailable: InputSignal = input(false); + /** + * Property page + * @readonly + * + * @description + * Displayed journal page. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly page: InputSignal = input(1); + + /** + * Property draftEntryState + * @readonly + * + * @description + * Independent current-entry read for a saved correction outside the visible page. + * + * @access public + * @since unreleased + * + * @type {InputSignal>} + */ + public readonly draftEntryState: InputSignal> = + input(idleCallState()); + + /** + * Property draftReviewRequested + * @readonly + * + * @description + * Explicit retry of the saved correction's current revision read. + * + * @access public + * @since unreleased + * + * @type {OutputEmitterRef} + */ + public readonly draftReviewRequested: OutputEmitterRef = output(); + + /** + * Property totalItems + * @readonly + * + * @description + * Exact saved entry count across pages, or unknown offline. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly totalItems: InputSignal = input(null); + + /** + * Property nextPage + * @readonly + * + * @description + * Known next journal page. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly nextPage: InputSignal = input(null); + + /** + * Property historyStates + * @readonly + * + * @description + * Independent expanded history request states. + * + * @access public + * @since unreleased + * + * @type {InputSignal>>>>} + */ + public readonly historyStates: InputSignal< + Readonly>>> + > = input>>>>({}); + + /** + * Property pageChanged + * @readonly + * + * @description + * Explicit journal page navigation. + * + * @access public + * @since unreleased + * + * @type {OutputEmitterRef} + */ + public readonly pageChanged: OutputEmitterRef = output(); + + /** + * Property historyExpanded + * @readonly + * + * @description + * Entry whose immutable revision history was expanded. + * + * @access public + * @since unreleased + * + * @type {OutputEmitterRef} + */ + public readonly historyExpanded: OutputEmitterRef = output(); + + /** + * Property olderVersionsRequested + * @readonly + * + * @description + * Explicit earlier history-page continuation or retry. + * + * @access public + * @since unreleased + * + * @type {OutputEmitterRef} + */ + public readonly olderVersionsRequested: OutputEmitterRef = output(); + /** * Property closed * @readonly @@ -479,6 +610,7 @@ export class InterventionTimeSheet { * @since 1.0.0 * * @param {string} id - Member identifier. + * * @returns {string} */ protected memberName(id: string): string { @@ -487,6 +619,25 @@ export class InterventionTimeSheet { ); } + /** + * Method changePage + * @method changePage + * + * @description + * Emits a known journal continuation without inferring unavailable offline pages. + * + * @access protected + * @since unreleased + * + * @param {number | null} page - Known next journal page. + * + * @returns {void} Emits the explicit page request. + */ + protected changePage(page: number | null): void { + if (page !== null && !this.pending() && this.readState().status !== 'pending') + this.pageChanged.emit(page); + } + /** * Method begin * @method begin @@ -522,6 +673,7 @@ export class InterventionTimeSheet { * @since 1.0.0 * * @param {InterventionTimeEntryView} entry - Entry to correct. + * * @returns {void} */ protected correct(entry: InterventionTimeEntryView): void { @@ -549,6 +701,7 @@ export class InterventionTimeSheet { * @since 1.0.0 * * @param {InterventionTimeEntryView} entry - Journal row. + * * @returns {boolean} */ protected canCorrect(entry: InterventionTimeEntryView): boolean { diff --git a/src/app/features/organization/features/interventions/ui/sheets/intervention-time-sheet/testing/intervention-time-sheet.component.spec.ts b/src/app/features/organization/features/interventions/ui/sheets/intervention-time-sheet/testing/intervention-time-sheet.component.spec.ts index 81b442407..9d3309a32 100644 --- a/src/app/features/organization/features/interventions/ui/sheets/intervention-time-sheet/testing/intervention-time-sheet.component.spec.ts +++ b/src/app/features/organization/features/interventions/ui/sheets/intervention-time-sheet/testing/intervention-time-sheet.component.spec.ts @@ -79,6 +79,8 @@ const entry = (overrides: Partial = {}): Intervention createdAt: '2026-09-21T09:00:00Z', updatedAt: '2026-09-21T09:00:00Z', versions: [], + totalVersions: 4, + nextBeforeRevision: 4, ...overrides, }); @@ -142,6 +144,90 @@ describe('InterventionTimeSheet', () => { fixture.destroy(); vi.unstubAllGlobals(); }); + it('emits explicit page navigation and labels the saved total independently from visible rows', async () => { + const changed = vi.fn(); + fixture.componentInstance.pageChanged.subscribe(changed); + fixture.componentRef.setInput('entries', [entry()]); + fixture.componentRef.setInput('totalItems', 65); + fixture.componentRef.setInput('nextPage', 2); + await fixture.whenStable(); + const next = document.querySelector('[data-testid="time-page-next"]'); + const previous = document.querySelector( + '[data-testid="time-page-previous"]', + ); + expect(previous?.disabled).toBe(true); + expect(document.body.textContent).toContain('Saved entries: 65'); + next?.click(); + expect(changed).toHaveBeenLastCalledWith(2); + fixture.componentRef.setInput('page', 2); + fixture.componentRef.setInput('nextPage', null); + await fixture.whenStable(); + expect(next?.disabled).toBe(true); + previous?.click(); + expect(changed).toHaveBeenLastCalledWith(1); + }); + it('requests history only on expansion and exposes explicit earlier-page continuation and retry', async () => { + const expanded = vi.fn(); + const older = vi.fn(); + fixture.componentInstance.historyExpanded.subscribe(expanded); + fixture.componentInstance.olderVersionsRequested.subscribe(older); + fixture.componentRef.setInput('entries', [entry()]); + await fixture.whenStable(); + expect(expanded).not.toHaveBeenCalled(); + document.querySelector('[data-testid="time-history-entry-1"]')?.click(); + await fixture.whenStable(); + expect(expanded).toHaveBeenCalledExactlyOnceWith('entry-1'); + const data = { + '@id': '/time-entries/entry-1/versions', + '@type': 'InterventionTimeEntryVersions', + id: 'entry-1', + versions: [], + totalItems: 4, + itemsPerPage: 2, + nextBeforeRevision: 3, + }; + fixture.componentRef.setInput('historyStates', { 'entry-1': successCallState(data) }); + await fixture.whenStable(); + document.querySelector('[data-testid="time-history-more-entry-1"]')?.click(); + expect(older).toHaveBeenLastCalledWith('entry-1'); + fixture.componentRef.setInput('historyStates', { + 'entry-1': errorCallState(toStoreError(new Error('History unavailable')), data), + }); + await fixture.whenStable(); + const retry = [...document.querySelectorAll('button')].find( + (button) => button.textContent?.trim() === 'Try again', + ); + retry?.click(); + expect(older).toHaveBeenCalledTimes(2); + expect(document.body.textContent).toContain('History unavailable'); + }); + it('never requests correction history offline and identifies an uncached journal page', async () => { + const expanded = vi.fn(); + fixture.componentInstance.historyExpanded.subscribe(expanded); + fixture.componentRef.setInput('entries', [entry()]); + fixture.componentRef.setInput('offline', true); + fixture.componentRef.setInput('historyUnavailable', true); + fixture.componentRef.setInput('page', 2); + await fixture.whenStable(); + document.querySelector('[data-testid="time-history-entry-1"]')?.click(); + await fixture.whenStable(); + expect(expanded).not.toHaveBeenCalled(); + expect(document.body.textContent).toContain( + 'This journal page has not been saved on this device.', + ); + expect(document.body.textContent).toContain('Correction history is unavailable offline.'); + }); + it('reviews the saved correction revision even when its target is outside the displayed page', async () => { + const current = entry({ revision: 7, totalVersions: 7, nextBeforeRevision: 7 }); + fixture.componentRef.setInput('entries', []); + fixture.componentRef.setInput('draft', draft); + fixture.componentRef.setInput('draftEntryState', successCallState(current)); + await fixture.whenStable(); + expect(fixture.componentInstance['draftServerEntry']()).toEqual(current); + expect(document.body.textContent).toContain('This entry changed since your correction began.'); + fixture.componentInstance['reviewCorrection'](); + expect(drafts.at(-1)).toEqual({ ...draft, baseRevision: 7 }); + }); it('starts a fresh journal draft for the actor without borrowing the work item revision', async () => { fixture.componentInstance['begin'](); diff --git a/src/app/features/organization/features/interventions/ui/sheets/intervention-work-item-sheet/intervention-work-item-sheet.component.html b/src/app/features/organization/features/interventions/ui/sheets/intervention-work-item-sheet/intervention-work-item-sheet.component.html index 0a1d88721..247b8c8d7 100644 --- a/src/app/features/organization/features/interventions/ui/sheets/intervention-work-item-sheet/intervention-work-item-sheet.component.html +++ b/src/app/features/organization/features/interventions/ui/sheets/intervention-work-item-sheet/intervention-work-item-sheet.component.html @@ -13,11 +13,13 @@

Add work item

- Only the action is required; target and assignee can be set now or during execution. + Choose the action and, for maintenance, the equipment concerned. Assignment and estimates + are optional.

| null>} + */ + public readonly prefill = input | null>( + null, + ); /** * Property workloadOrganizationId * @readonly @@ -100,17 +113,27 @@ export class InterventionWorkItemSheet { /** * Property catalogueSearched * @readonly - * @description Requests remote options without replacing the draft. + * + * @description + * Requests remote options without replacing the draft. + * * @access public * @since 1.0.0 + * + * @type {OutputEmitterRef} */ public readonly catalogueSearched = output(); /** * Property catalogues * @readonly - * @description Loaded coverage and failures by preparation source. + * + * @description + * Loaded coverage and failures by preparation source. + * * @access public * @since 1.0.0 + * + * @type {InputSignal>>} */ public readonly catalogues = input< Partial> @@ -118,9 +141,14 @@ export class InterventionWorkItemSheet { /** * Property catalogueRequested * @readonly - * @description Requests another source page while preserving the form. + * + * @description + * Requests another source page while preserving the form. + * * @access public * @since 1.0.0 + * + * @type {OutputEmitterRef} */ public readonly catalogueRequested = output(); @@ -128,9 +156,13 @@ export class InterventionWorkItemSheet { /** * Property visible * @readonly - * @description Whether the panel is open. Owned by the page. + * + * @description + * Whether the panel is open. Owned by the page. + * * @access public * @since 1.0.0 + * * @type {InputSignal} */ public readonly visible: InputSignal = input(false); @@ -138,9 +170,13 @@ export class InterventionWorkItemSheet { /** * Property pending * @readonly - * @description Whether the creation request is in flight. + * + * @description + * Whether the creation request is in flight. + * * @access public * @since 1.0.0 + * * @type {InputSignal} */ public readonly pending: InputSignal = input(false); @@ -148,9 +184,13 @@ export class InterventionWorkItemSheet { /** * Property disabled * @readonly - * @description Whether the scope may still grow, forwarded to the form. + * + * @description + * Whether the scope may still grow, forwarded to the form. + * * @access public * @since 1.0.0 + * * @type {InputSignal} */ public readonly disabled: InputSignal = input(false); @@ -158,9 +198,13 @@ export class InterventionWorkItemSheet { /** * Property serverError * @readonly - * @description Whatever the creation failed with, forwarded to the form. + * + * @description + * Whatever the creation failed with, forwarded to the form. + * * @access public * @since 1.0.0 + * * @type {InputSignal} */ public readonly serverError: InputSignal = input(null); @@ -168,9 +212,13 @@ export class InterventionWorkItemSheet { /** * Property targetOptions * @readonly - * @description The facilities and equipment an item can point at. + * + * @description + * The facilities and equipment an item can point at. + * * @access public * @since 1.0.0 + * * @type {InputSignal} */ public readonly targetOptions: InputSignal = input< @@ -180,9 +228,13 @@ export class InterventionWorkItemSheet { /** * Property memberOptions * @readonly - * @description The members an item can be assigned to. + * + * @description + * The members an item can be assigned to. + * * @access public * @since 1.0.0 + * * @type {InputSignal} */ public readonly memberOptions: InputSignal = input< @@ -194,9 +246,13 @@ export class InterventionWorkItemSheet { /** * Property visibleChange * @readonly - * @description The panel wants to open or close. + * + * @description + * The panel wants to open or close. + * * @access public * @since 1.0.0 + * * @type {OutputEmitterRef} */ public readonly visibleChange: OutputEmitterRef = output(); @@ -204,9 +260,13 @@ export class InterventionWorkItemSheet { /** * Property submitted * @readonly - * @description The form's validated item, forwarded untouched. + * + * @description + * The form's validated item, forwarded untouched. + * * @access public * @since 1.0.0 + * * @type {OutputEmitterRef} */ public readonly submitted: OutputEmitterRef = @@ -240,9 +300,13 @@ export class InterventionWorkItemSheet { /** * Property sheetState * @readonly - * @description The panel state, derived from {@link visible} so there is no second copy of the truth. + * + * @description + * The panel state, derived from {@link visible} so there is no second copy of the truth. + * * @access protected * @since 1.0.0 + * * @type {Signal} */ protected readonly sheetState: Signal = computed(() => @@ -252,9 +316,14 @@ export class InterventionWorkItemSheet { /** * Property side * @readonly - * @description The panel's side — `'bottom'` below `sm`, `'right'` at and above it (`DESIGN.md` "Action Surfaces" rule 2). + * + * @description + * The panel's side — `'bottom'` below `sm`, `'right'` at and above it (`DESIGN.md` "Action + * Surfaces" rule 2). + * * @access protected * @since 1.1.0 + * * @type {Signal<'right' | 'bottom'>} */ protected readonly side: Signal<'right' | 'bottom'> = sheetSide(); @@ -277,9 +346,14 @@ export class InterventionWorkItemSheet { /** * Property unsavedChangesDialogState * @readonly - * @description Open state of the shared {@link UnsavedChangesDialog}, raised by {@link requestClose} when {@link dirty} is true. + * + * @description + * Open state of the shared {@link UnsavedChangesDialog}, raised by {@link requestClose} when + * {@link dirty} is true. + * * @access protected * @since 7.1.0 + * * @type {WritableSignal} */ protected readonly unsavedChangesDialogState: WritableSignal = @@ -364,9 +438,13 @@ export class InterventionWorkItemSheet { /** * Method onUnsavedChangesConfirmed * @method onUnsavedChangesConfirmed - * @description The operator chose to discard the draft — closes both the confirmation and the panel itself. + * + * @description + * The operator chose to discard the draft — closes both the confirmation and the panel itself. + * * @access protected * @since 7.1.0 + * * @returns {void} */ protected onUnsavedChangesConfirmed(): void { @@ -377,9 +455,13 @@ export class InterventionWorkItemSheet { /** * Method onUnsavedChangesDismissed * @method onUnsavedChangesDismissed - * @description The operator chose to keep editing — closes the confirmation only, the panel stays open. + * + * @description + * The operator chose to keep editing — closes the confirmation only, the panel stays open. + * * @access protected * @since 7.1.0 + * * @returns {void} */ protected onUnsavedChangesDismissed(): void { diff --git a/src/app/features/organization/features/interventions/ui/sheets/intervention-work-item-sheet/testing/intervention-work-item-sheet.component.spec.ts b/src/app/features/organization/features/interventions/ui/sheets/intervention-work-item-sheet/testing/intervention-work-item-sheet.component.spec.ts index 5e2a7bce3..c85b2b430 100644 --- a/src/app/features/organization/features/interventions/ui/sheets/intervention-work-item-sheet/testing/intervention-work-item-sheet.component.spec.ts +++ b/src/app/features/organization/features/interventions/ui/sheets/intervention-work-item-sheet/testing/intervention-work-item-sheet.component.spec.ts @@ -70,7 +70,7 @@ describe('InterventionWorkItemSheet', () => { await fixture.whenStable(); expect(inSheet('[data-slot="sheet-description"]')?.textContent?.trim()).toBe( - 'Only the action is required; target and assignee can be set now or during execution.', + 'Choose the action and, for maintenance, the equipment concerned. Assignment and estimates are optional.', ); }); diff --git a/src/app/features/organization/features/interventions/ui/tables/intervention-equipment-table/intervention-equipment-table.component.html b/src/app/features/organization/features/interventions/ui/tables/intervention-equipment-table/intervention-equipment-table.component.html index dfb42414b..a6899a5a6 100644 --- a/src/app/features/organization/features/interventions/ui/tables/intervention-equipment-table/intervention-equipment-table.component.html +++ b/src/app/features/organization/features/interventions/ui/tables/intervention-equipment-table/intervention-equipment-table.component.html @@ -46,7 +46,7 @@

({ value, @@ -141,6 +152,20 @@ const EQUIPMENT_STATUS_OPTIONS: readonly CollectionFilterOption[] = EQUIPMENT_ST changeDetection: ChangeDetectionStrategy.OnPush, }) export class InterventionEquipmentTable { + /** + * Property equipmentTypeOptions + * @readonly + * + * @description + * Complete authorized catalog, including archived descriptors needed by historical rows. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly equipmentTypeOptions = input([]); + /** * Property source * @readonly @@ -185,9 +210,13 @@ export class InterventionEquipmentTable { /** * Property organizationId * @readonly - * @description The workspace owning the intervention, so a row can link into its equipment's own record. + * + * @description + * The workspace owning the intervention, so a row can link into its equipment's own record. + * * @access public * @since 1.3.0 + * * @type {InputSignal} */ public readonly organizationId: InputSignal = input.required(); @@ -195,9 +224,13 @@ export class InterventionEquipmentTable { /** * Property items * @readonly - * @description The equipment linked to this intervention. + * + * @description + * The equipment linked to this intervention. + * * @access public * @since 1.0.0 + * * @type {InputSignal} */ public readonly items: InputSignal = @@ -206,9 +239,13 @@ export class InterventionEquipmentTable { /** * Property loading * @readonly - * @description Whether the tab's own fetch is in flight. + * + * @description + * Whether the tab's own fetch is in flight. + * * @access public * @since 1.0.0 + * * @type {InputSignal} */ public readonly loading: InputSignal = input(false); @@ -216,9 +253,13 @@ export class InterventionEquipmentTable { /** * Property error * @readonly - * @description The tab's own fetch error, or `null`. + * + * @description + * The tab's own fetch error, or `null`. + * * @access public * @since 1.0.0 + * * @type {InputSignal} */ public readonly error: InputSignal = input(null); @@ -226,9 +267,13 @@ export class InterventionEquipmentTable { /** * Property totalItems * @readonly - * @description Total linked equipment the server reports, across all pages. + * + * @description + * Total linked equipment the server reports, across all pages. + * * @access public * @since 1.2.0 + * * @type {InputSignal} */ public readonly totalItems: InputSignal = input(0); @@ -236,9 +281,13 @@ export class InterventionEquipmentTable { /** * Property loadingMore * @readonly - * @description Whether the next page of linked equipment is being fetched. + * + * @description + * Whether the next page of linked equipment is being fetched. + * * @access public * @since 1.2.0 + * * @type {InputSignal} */ public readonly loadingMore: InputSignal = input(false); @@ -261,9 +310,13 @@ export class InterventionEquipmentTable { /** * Property loadMoreRequested * @readonly - * @description Emits when the user asks for the next page of linked equipment. + * + * @description + * Emits when the user asks for the next page of linked equipment. + * * @access public * @since 1.2.0 + * * @type {OutputEmitterRef} */ public readonly loadMoreRequested: OutputEmitterRef = output(); @@ -285,9 +338,13 @@ export class InterventionEquipmentTable { /** * Property query * @readonly - * @description Controlled search and filter criteria retained by the owning page store. + * + * @description + * Controlled search and filter criteria retained by the owning page store. + * * @access public * @since 6.2.0 + * * @type {InputSignal} */ public readonly query: InputSignal = @@ -394,7 +451,9 @@ export class InterventionEquipmentTable { * * @type {readonly CollectionFilterOption[]} */ - protected readonly typeOptions: readonly CollectionFilterOption[] = EQUIPMENT_TYPE_OPTIONS; + protected readonly typeOptions: Signal = computed(() => + this.equipmentTypeOptions().length ? this.equipmentTypeOptions() : EQUIPMENT_TYPE_OPTIONS, + ); /** * Property statusOptions @@ -485,7 +544,7 @@ export class InterventionEquipmentTable { * @access protected * @since 1.0.0 * - * @type {Signal< Readonly | undefined>> >} + * @type {Signal | undefined>>>} */ protected readonly filterTemplates: Signal< Readonly | undefined>> @@ -541,9 +600,13 @@ export class InterventionEquipmentTable { /** * Property skeletonColumnWidths * @readonly - * @description One literal width or alignment class per rendered column, handed to the shared skeleton rows. + * + * @description + * One literal width or alignment class per rendered column, handed to the shared skeleton rows. + * * @access protected * @since 2.0.0 + * * @type {readonly string[]} */ protected readonly skeletonColumnWidths: readonly string[] = [ @@ -571,15 +634,23 @@ export class InterventionEquipmentTable { * @returns {string} The humanized type. */ protected typeLabelOf(item: EquipmentOutput): string { - return item.type.replaceAll('_', ' '); + return ( + this.equipmentTypeOptions().find((option) => option.value === item.type)?.label ?? + item.type.replaceAll('_', ' ') + ); } /** * Method searchableTextOf - * @description Builds the visible equipment fields an operator can search. + * + * @description + * Builds the visible equipment fields an operator can search. + * * @access protected * @since 6.2.0 + * * @param {EquipmentOutput} item - The equipment being indexed. + * * @returns {string} Normalized searchable row text. */ protected searchableTextOf(item: EquipmentOutput): string { @@ -602,10 +673,15 @@ export class InterventionEquipmentTable { /** * Method onSearchQueryChanged - * @description Keeps the table-local search in sync with the shared search box. + * + * @description + * Keeps the table-local search in sync with the shared search box. + * * @access protected * @since 6.2.0 + * * @param {string} query - The text entered by the operator. + * * @returns {void} */ protected onSearchQueryChanged(query: string): void { @@ -614,10 +690,15 @@ export class InterventionEquipmentTable { /** * Method toggleFiltersVisible - * @description Reacts to the shared filter toggle's requested visibility. + * + * @description + * Reacts to the shared filter toggle's requested visibility. + * * @access protected * @since 6.2.0 + * * @param {boolean} visible - Whether the filter bar should be mounted. + * * @returns {void} */ protected toggleFiltersVisible(visible: boolean): void { @@ -635,6 +716,7 @@ export class InterventionEquipmentTable { * @since 1.0.0 * * @param {string} key - The filter field key. + * * @returns {void} No value is returned. */ protected onFieldPicked(key: string): void { @@ -652,6 +734,7 @@ export class InterventionEquipmentTable { * @since 1.0.0 * * @param {string} key - The filter field key. + * * @returns {void} No value is returned. */ protected onFieldRemoved(key: string): void { @@ -688,11 +771,12 @@ export class InterventionEquipmentTable { * @since 1.0.0 * * @param {string | null} value - The selected filter value, or `null` when cleared. + * * @returns {void} No value is returned. */ protected onTypeFilterChanged(value: string | null): void { const selected: EquipmentType | null = - EQUIPMENT_TYPE_OPTIONS.find((option) => option.value === value)?.value ?? null; + this.typeOptions().find((option) => option.value === value)?.value ?? null; this.openFilterKey.set(null); this.emitQuery({ type: selected }); } @@ -708,6 +792,7 @@ export class InterventionEquipmentTable { * @since 1.0.0 * * @param {string | null} value - The selected filter value, or `null` when cleared. + * * @returns {void} No value is returned. */ protected onStatusFilterChanged(value: string | null): void { @@ -728,7 +813,9 @@ export class InterventionEquipmentTable { * @access private * @since 1.0.0 * - * @param {Partial} overrides - Criteria to merge into the current table query. + * @param {Partial} overrides - Criteria to merge into the + * current table query. + * * @returns {void} No value is returned. */ private emitQuery(overrides: Partial = {}): void { @@ -746,6 +833,7 @@ export class InterventionEquipmentTable { * @since 1.0.0 * * @param {EquipmentFilterKey} key - The filter field key. + * * @returns {BrnOverlayState} The current overlay state for the filter. */ protected fieldPopoverState(key: EquipmentFilterKey): BrnOverlayState { @@ -764,6 +852,7 @@ export class InterventionEquipmentTable { * * @param {EquipmentFilterKey} key - The filter field key. * @param {BrnOverlayState} state - The filter popover's next state. + * * @returns {void} No value is returned. */ protected onFilterPopoverStateChanged(key: EquipmentFilterKey, state: BrnOverlayState): void { @@ -777,10 +866,15 @@ export class InterventionEquipmentTable { /** * Method brandModelOf - * @description The brand and model, joined for one cell, or `null` when neither is set. + * + * @description + * The brand and model, joined for one cell, or `null` when neither is set. + * * @access protected * @since 1.0.0 + * * @param {EquipmentOutput} item - The equipment being rendered. + * * @returns {string | null} The joined label, or `null`. */ protected brandModelOf(item: EquipmentOutput): string | null { diff --git a/src/app/features/organization/features/interventions/ui/tables/intervention-equipment-table/testing/intervention-equipment-table.component.spec.ts b/src/app/features/organization/features/interventions/ui/tables/intervention-equipment-table/testing/intervention-equipment-table.component.spec.ts index 735c2926c..c98e227c6 100644 --- a/src/app/features/organization/features/interventions/ui/tables/intervention-equipment-table/testing/intervention-equipment-table.component.spec.ts +++ b/src/app/features/organization/features/interventions/ui/tables/intervention-equipment-table/testing/intervention-equipment-table.component.spec.ts @@ -385,6 +385,19 @@ describe('InterventionEquipmentTable', () => { expect(requested).toHaveBeenCalledTimes(1); }); + it('retains archived custom catalog labels and permits their historical type filter', async () => { + fixture.componentRef.setInput('equipmentTypeOptions', [ + { value: 'custom_foam', label: 'Retired foam cabinet' }, + ]); + fixture.componentRef.setInput('items', [equipment({ type: 'custom_foam' })]); + await fixture.whenStable(); + expect(root().textContent).toContain('Retired foam cabinet'); + const queryChanged = vi.fn(); + fixture.componentInstance.queryChanged.subscribe(queryChanged); + fixture.componentInstance['onTypeFilterChanged']('custom_foam'); + expect(queryChanged).toHaveBeenCalledWith(expect.objectContaining({ type: 'custom_foam' })); + }); + it('should disable the "Show more" button and show the spinner label while loading more', async () => { fixture.componentRef.setInput('items', [equipment()]); fixture.componentRef.setInput('totalItems', 2); diff --git a/src/app/features/organization/features/interventions/ui/tables/intervention-work-item-table/intervention-work-item-table.component.html b/src/app/features/organization/features/interventions/ui/tables/intervention-work-item-table/intervention-work-item-table.component.html index 4758a1b77..e2f432662 100644 --- a/src/app/features/organization/features/interventions/ui/tables/intervention-work-item-table/intervention-work-item-table.component.html +++ b/src/app/features/organization/features/interventions/ui/tables/intervention-work-item-table/intervention-work-item-table.component.html @@ -258,6 +258,10 @@ >{{ item.skipReason }} } + @@ -526,6 +530,10 @@ [ngTemplateOutlet]="effortSummary" [ngTemplateOutletContext]="{ $implicit: item }" /> + @if (item.assigneeProfile; as assignee) {
@@ -745,6 +753,58 @@
+ + @if (item.executionResult; as result) { +
+ + Performed {{ result.performedAt | appOrgDate: 'datetime' : regionalFormatting() }} + {{ executionOutcomeLabel(result) }} + @if (result.state === 'validated') { + Validated + } @else { + Pending review + } + +

{{ result.workPerformed }}

+ @if (result.history?.length) { + + +
+ @for (attempt of result.history; track $index) { +
+ + Performed + {{ attempt.performedAt | appOrgDate: 'datetime' : regionalFormatting() }} + {{ executionOutcomeLabel(attempt) }} + +

{{ attempt.workPerformed }}

+
+ } +
+
+ } +
+ } +
+
} result - Recorded work + * result. + * + * @returns {string} Human-readable result. + */ + protected executionOutcomeLabel( + result: Pick, + ): string { + switch (result.outcome) { + case 'successful': + return $localize`:@@intervention.execution.successful:Successful`; + case 'failed': + return $localize`:@@intervention.execution.failed:Unsuccessful — work still required`; + case 'performed': + return $localize`:@@intervention.execution.performed:Performed`; + default: + return ''; + } + } + /** * Method canToggleItem * @method canToggleItem diff --git a/src/app/features/organization/features/interventions/ui/tables/intervention-work-item-table/testing/intervention-work-item-table.component.spec.ts b/src/app/features/organization/features/interventions/ui/tables/intervention-work-item-table/testing/intervention-work-item-table.component.spec.ts index 3a3645687..cb6ee0aa9 100644 --- a/src/app/features/organization/features/interventions/ui/tables/intervention-work-item-table/testing/intervention-work-item-table.component.spec.ts +++ b/src/app/features/organization/features/interventions/ui/tables/intervention-work-item-table/testing/intervention-work-item-table.component.spec.ts @@ -119,6 +119,78 @@ describe('InterventionWorkItemTable', () => { expect(rows()[0]?.textContent).toContain('Extinguisher A-12'); }); + it('shows actual failed work separately from task status and update timestamps', async () => { + fixture.componentRef.setInput('items', [ + item({ + action: 'repair', + status: 'in_progress', + executionResult: { + equipmentId: 'eq-1', + performedAt: '2026-03-01T11:00:00+01:00', + outcome: 'failed', + workPerformed: 'Leak persists; follow-up needed.', + operationId: null, + occurrenceId: null, + state: 'staged', + validatedAt: null, + }, + }), + ]); + await fixture.whenStable(); + expect(root().textContent).toContain('Unsuccessful — work still required'); + expect(root().textContent).toContain('Pending review'); + expect(root().textContent).toContain('Leak persists; follow-up needed.'); + expect(root().textContent).toContain('In progress'); + expect( + root().querySelector('[data-testid="work-item-execution-summary"]')?.textContent, + ).not.toContain('Jan 5'); + }); + + it('retains earlier failed attempts behind a native history disclosure after successful work', async () => { + fixture.componentRef.setInput('items', [ + item({ + status: 'completed', + action: 'repair', + executionResult: { + equipmentId: 'eq-1', + performedAt: '2026-03-02T11:00:00+01:00', + outcome: 'successful', + workPerformed: 'Pressure test passed.', + operationId: null, + occurrenceId: null, + state: 'validated', + validatedAt: '2026-03-03T11:00:00Z', + history: [ + { + equipmentId: 'eq-1', + performedAt: '2026-03-01T11:00:00+01:00', + outcome: 'failed', + workPerformed: 'Leak persists; a new seal is required.', + operationId: null, + occurrenceId: null, + state: 'staged', + validatedAt: null, + }, + ], + }, + }), + ]); + await fixture.whenStable(); + const trigger = root().querySelector( + '[data-testid="execution-history-trigger"]', + ); + expect(trigger?.textContent).toContain('Previous attempts'); + trigger?.click(); + await fixture.whenStable(); + expect(root().querySelector('[data-testid="execution-history"]')?.textContent).toContain( + 'Leak persists; a new seal is required.', + ); + expect(root().querySelector('[data-testid="execution-history"]')?.textContent).toContain( + 'Unsuccessful — work still required', + ); + expect(root().textContent).toContain('Pressure test passed.'); + }); + it('exposes locally saved work as native output in the table and cards', async () => { fixture.componentRef.setInput('queuedIds', new Set(['wi-1'])); await fixture.whenStable(); diff --git a/src/app/features/organization/features/interventions/utils/index.ts b/src/app/features/organization/features/interventions/utils/index.ts index a21796b09..71f95d986 100644 --- a/src/app/features/organization/features/interventions/utils/index.ts +++ b/src/app/features/organization/features/interventions/utils/index.ts @@ -41,8 +41,10 @@ export { export { interventionRecurrenceFrequencyLabel } from './intervention-recurrence-frequency-label/intervention-recurrence-frequency-label.utils'; export { projectInterventionWorkspace, + projectInterventionWorkItemExecutionResult, searchSavedWorkItems, searchSavedChanges, } from './intervention-workspace-projection/intervention-workspace-projection.utils'; export { projectInterventionTime } from './project-intervention-time/project-intervention-time.utils'; export { orderInterventionWorkItems } from './order-intervention-work-items/order-intervention-work-items.utils'; +export { resolveInterventionEquipmentContext } from './intervention-equipment-context/intervention-equipment-context.utils'; diff --git a/src/app/features/organization/features/interventions/utils/intervention-equipment-context/intervention-equipment-context.utils.ts b/src/app/features/organization/features/interventions/utils/intervention-equipment-context/intervention-equipment-context.utils.ts new file mode 100644 index 000000000..b6d80e75e --- /dev/null +++ b/src/app/features/organization/features/interventions/utils/intervention-equipment-context/intervention-equipment-context.utils.ts @@ -0,0 +1,37 @@ +import type { InterventionEquipmentContext } from '../../models/intervention-equipment-context/intervention-equipment-context.interface'; +import type { InterventionWorkItemAction } from '../../models/intervention-work-item/intervention-work-item-action.type'; + +/** + * Function resolveInterventionEquipmentContext + * + * @description + * Validates equipment-dossier route hints before they can seed a planning draft. + * + * @access public + * @since unreleased + * + * @param {string | undefined} equipmentId - Equipment UUID from the route query. + * @param {string | undefined} workAction - Supported equipment work action. + * @param {string | undefined} siteId - Optional root-site UUID. + * + * @returns {InterventionEquipmentContext | null} Safe preparation context or no context. + */ +export function resolveInterventionEquipmentContext( + equipmentId: string | undefined, + workAction: string | undefined, + siteId?: string, +): InterventionEquipmentContext | null { + const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + const action = workAction ?? 'inspection'; + if ( + !equipmentId || + !uuid.test(equipmentId) || + !['inspection', 'maintenance', 'repair', 'replacement'].includes(action) + ) + return null; + return { + target: `/api/equipment/${equipmentId}`, + action: action as InterventionWorkItemAction, + site: siteId && uuid.test(siteId) ? `/api/facilities/${siteId}` : '', + }; +} diff --git a/src/app/features/organization/features/interventions/utils/intervention-equipment-context/testing/intervention-equipment-context.utils.spec.ts b/src/app/features/organization/features/interventions/utils/intervention-equipment-context/testing/intervention-equipment-context.utils.spec.ts new file mode 100644 index 000000000..90884ae9d --- /dev/null +++ b/src/app/features/organization/features/interventions/utils/intervention-equipment-context/testing/intervention-equipment-context.utils.spec.ts @@ -0,0 +1,26 @@ +import { resolveInterventionEquipmentContext } from '../intervention-equipment-context.utils'; + +describe('resolveInterventionEquipmentContext', () => { + const equipmentId = '00000000-0000-4000-8000-000000000001'; + const siteId = '00000000-0000-4000-8000-000000000002'; + + it('prepares only canonical equipment actions and optional root-site hints', () => { + expect(resolveInterventionEquipmentContext(equipmentId, 'repair', siteId)).toEqual({ + target: `/api/equipment/${equipmentId}`, + action: 'repair', + site: `/api/facilities/${siteId}`, + }); + expect(resolveInterventionEquipmentContext(equipmentId, undefined)).toEqual({ + target: `/api/equipment/${equipmentId}`, + action: 'inspection', + site: '', + }); + }); + + it('rejects malformed identifiers and unrelated action hints before they reach a draft', () => { + expect(resolveInterventionEquipmentContext('/api/equipment/foreign', 'repair')).toBeNull(); + expect(resolveInterventionEquipmentContext(equipmentId, 'site_setup')).toBeNull(); + expect(resolveInterventionEquipmentContext(undefined, 'maintenance')).toBeNull(); + expect(resolveInterventionEquipmentContext(equipmentId, 'repair', 'invalid')?.site).toBe(''); + }); +}); diff --git a/src/app/features/organization/features/interventions/utils/intervention-workspace-projection/intervention-workspace-projection.utils.ts b/src/app/features/organization/features/interventions/utils/intervention-workspace-projection/intervention-workspace-projection.utils.ts index 5b244e5fc..47396d472 100644 --- a/src/app/features/organization/features/interventions/utils/intervention-workspace-projection/intervention-workspace-projection.utils.ts +++ b/src/app/features/organization/features/interventions/utils/intervention-workspace-projection/intervention-workspace-projection.utils.ts @@ -7,6 +7,8 @@ import { type InterventionChangeOutput, type InterventionWorkItemTableQuery, type InterventionChangeTableQuery, + type InterventionWorkItemExecutionResultInput, + type InterventionWorkItemExecutionResultOutput, } from '@features/organization/features/interventions/models'; import { formatInterventionChangePatch, @@ -20,12 +22,14 @@ import { * Overlays only outstanding local operations, including conflicts, on complete data. * Applying the same outbox twice is idempotent. Unrelated fresh server fields remain authoritative. * + * @since 6.2.0 + * * @param {InterventionWorkspaceData} base - Complete saved or freshly fetched workspace. - * @param {readonly InterventionOutboxOperation[]} operations - Remaining local intent in queue order. + * @param {readonly InterventionOutboxOperation[]} operations - Remaining local intent in queue + * order. * @param {InterventionWorkspaceData} saved - Saved labels for pending local associations. - * @returns {InterventionWorkspaceData} Projected workspace without mutating inputs. * - * @since 6.2.0 + * @returns {InterventionWorkspaceData} Projected workspace without mutating inputs. */ export function projectInterventionWorkspace( base: InterventionWorkspaceData, @@ -35,6 +39,21 @@ export function projectInterventionWorkspace( const intervention = { ...base.intervention }; const workItems = new Map(base.workItems.map((item) => [item.id, item])); const changes = new Map(base.changes.map((change) => [change.id, change])); + const latestResults = new Map(); + for (const operation of operations) { + if ( + operation.interventionId === intervention.id && + operation.type === 'work-item.update' && + operation.payload.executionResult !== undefined + ) + latestResults.set(operation.payload.workItemId, operation.payload.executionResult); + } + const projectedResultIds = new Set( + [...latestResults].flatMap(([id, result]) => { + const current = workItems.get(id)?.executionResult; + return current && !current.authorId && equalExecutionFacts(current, result) ? [id] : []; + }), + ); let workChanged = false; for (const operation of operations) { if (operation.interventionId !== intervention.id) continue; @@ -44,7 +63,13 @@ export function projectInterventionWorkspace( break; } case 'work-item.update': { - workChanged = applyWorkItemUpdate(workItems, operation, saved) || workChanged; + workChanged = + applyWorkItemUpdate( + workItems, + operation, + saved, + projectedResultIds.has(operation.payload.workItemId), + ) || workChanged; break; } case 'change.create': { @@ -84,6 +109,7 @@ export function projectInterventionWorkspace( * * @param {Map} workItems - Projected work items. * @param {InterventionOutboxOperationFor<'work-item.create'>} operation - Queued creation. + * * @returns {boolean} Whether a new row was added. */ function applyWorkItemCreation( @@ -108,17 +134,21 @@ function applyWorkItemCreation( * @param {Map} workItems - Projected work items. * @param {InterventionOutboxOperationFor<'work-item.update'>} operation - Queued update. * @param {InterventionWorkspaceData} saved - Saved rows and association labels. + * @param {boolean} isResultProjected - Whether the snapshot already contains the final local + * result. + * * @returns {boolean} Whether a row was updated or restored. */ function applyWorkItemUpdate( workItems: Map, operation: InterventionOutboxOperationFor<'work-item.update'>, saved: InterventionWorkspaceData, + isResultProjected: boolean, ): boolean { const { workItemId } = operation.payload; const item = workItems.get(workItemId) ?? saved.workItems.find((row) => row.id === workItemId); if (!item) return false; - workItems.set(workItemId, projectedUpdatedWorkItem(operation, item, saved)); + workItems.set(workItemId, projectedUpdatedWorkItem(operation, item, saved, isResultProjected)); return true; } @@ -133,6 +163,7 @@ function applyWorkItemUpdate( * * @param {Map} changes - Projected changes. * @param {InterventionOutboxOperationFor<'change.create'>} operation - Queued creation. + * * @returns {void} */ function applyChangeCreation( @@ -169,6 +200,7 @@ function applyChangeCreation( * @param {Map} changes - Projected changes. * @param {InterventionOutboxOperationFor<'change.update'>} operation - Queued update. * @param {InterventionWorkspaceData} saved - Saved rows available for restoration. + * * @returns {void} */ function applyChangeUpdate( @@ -183,11 +215,16 @@ function applyChangeUpdate( /** * Function projectedCreatedWorkItem - * @description Builds the optimistic row for an outstanding work-item creation. + * + * @description + * Builds the optimistic row for an outstanding work-item creation. + * + * @since 6.2.0 + * * @param {InterventionOutboxOperationFor<'work-item.create'>} operation - Queued creation. * @param {string} id - Stable client-generated identifier. + * * @returns {InterventionWorkItemOutput} Local projection. - * @since 6.2.0 */ function projectedCreatedWorkItem( operation: InterventionOutboxOperationFor<'work-item.create'>, @@ -200,6 +237,10 @@ function projectedCreatedWorkItem( id, intervention: input.intervention, action: input.action, + operationId: input.operationId ?? null, + occurrenceId: input.occurrenceId ?? null, + operationKind: input.operationKind ?? null, + executionResult: null, target: input.target ?? null, resultResource: input.resultResource ?? null, assignee: input.assignee ?? null, @@ -221,26 +262,45 @@ function projectedCreatedWorkItem( /** * Function projectedUpdatedWorkItem - * @description Applies a queued edit while retaining saved profile labels and clearing stale remaining effort. + * + * @description + * Applies a queued edit while retaining saved profile labels and clearing stale remaining effort. + * + * @since 6.2.0 + * * @param {InterventionOutboxOperationFor<'work-item.update'>} operation - Queued edit. * @param {InterventionWorkItemOutput} item - Current projected row. * @param {InterventionWorkspaceData} saved - Saved labels for existing associations. + * @param {boolean} isResultProjected - Whether the snapshot already contains the final local + * result. + * * @returns {InterventionWorkItemOutput} Updated local row. - * @since 6.2.0 */ function projectedUpdatedWorkItem( operation: InterventionOutboxOperationFor<'work-item.update'>, item: InterventionWorkItemOutput, saved: InterventionWorkspaceData, + isResultProjected: boolean, ): InterventionWorkItemOutput { - const { workItemId, clientId: _clientId, revision: _revision, ...fields } = operation.payload; + const { + workItemId, + clientId: _clientId, + revision: _revision, + executionResult, + ...fields + } = operation.payload; + const status = isResultProjected && executionResult !== undefined ? item.status : fields.status; return { ...item, ...fields, + ...(status !== undefined ? { status } : {}), + ...(executionResult !== undefined && !isResultProjected + ? { executionResult: projectInterventionWorkItemExecutionResult(item, executionResult) } + : {}), ...((item.status === 'completed' || item.status === 'skipped') && - fields.status && - fields.status !== 'completed' && - fields.status !== 'skipped' && + status && + status !== 'completed' && + status !== 'skipped' && fields.remainingMinutes === undefined ? { remainingMinutes: null } : {}), @@ -254,13 +314,88 @@ function projectedUpdatedWorkItem( }; } +/** + * Function projectInterventionWorkItemExecutionResult + * + * @description + * Stages a local execution fact and preserves earlier attempts without inventing provenance. + * + * @access public + * + * @param {InterventionWorkItemOutput} item - Work item carrying preventive identifiers. + * @param {InterventionWorkItemExecutionResultInput | null} input - Recorded fact or explicit + * removal. + * + * @returns {InterventionWorkItemExecutionResultOutput | null} Local result or unchanged recorded + * fact. + */ +export function projectInterventionWorkItemExecutionResult( + item: InterventionWorkItemOutput, + input: InterventionWorkItemExecutionResultInput | null, +): InterventionWorkItemExecutionResultOutput | null { + if (!input) return item.executionResult ?? null; + const previous = item.executionResult; + if (previous && equalExecutionFacts(previous, input)) return previous; + const history = [...(previous?.history ?? [])]; + if (previous) { + const { history: _history, ...attempt } = previous; + history.push(attempt); + } + return { + equipmentId: input.equipmentId, + performedAt: input.performedAt, + outcome: input.outcome, + workPerformed: input.workPerformed, + operationId: item.operationId ?? null, + occurrenceId: item.occurrenceId ?? null, + state: 'staged', + validatedAt: null, + history, + }; +} + +/** + * Function equalExecutionFacts + * + * @description + * Compares facts using the API's second-precision date and trimmed description normalization. + * + * @access private + * + * @param {InterventionWorkItemExecutionResultInput | null} previous - Previously recorded fact. + * @param {InterventionWorkItemExecutionResultInput | null} input - Pending fact or removal. + * + * @returns {boolean} Whether the declaration is unchanged. + */ +function equalExecutionFacts( + previous: InterventionWorkItemExecutionResultInput | null, + input: InterventionWorkItemExecutionResultInput | null, +): boolean { + return ( + previous === input || + (!!previous && + !!input && + previous.equipmentId === input.equipmentId && + previous.outcome === input.outcome && + previous.workPerformed.trim() === input.workPerformed.trim() && + previous.performedAt.replace(/\.\d+(?=Z|[+-]\d{2}:\d{2}$)/u, '').replace(/Z$/u, '+00:00') === + input.performedAt.replace(/\.\d+(?=Z|[+-]\d{2}:\d{2}$)/u, '').replace(/Z$/u, '+00:00')) + ); +} + /** * Function searchSavedWorkItems - * @description Evaluates offline criteria over all saved rows and available display labels. + * + * @description + * Evaluates offline criteria over all saved rows and available display labels. + * + * @since 6.2.0 + * * @param {readonly InterventionWorkItemOutput[]} items - Complete local projection. - * @param {InterventionWorkItemTableQuery} query - Controlled table criteria, including Remaining's scalar statuses. + * @param {InterventionWorkItemTableQuery} query - Controlled table criteria, including Remaining's + * scalar statuses. + * * @returns {readonly InterventionWorkItemOutput[]} Matching saved rows. - * @since 6.2.0 */ export function searchSavedWorkItems( items: readonly InterventionWorkItemOutput[], @@ -289,12 +424,17 @@ export function searchSavedWorkItems( /** * Function searchSavedChanges - * @description Evaluates offline history criteria over the complete local projection and patch values. + * + * @description + * Evaluates offline history criteria over the complete local projection and patch values. + * + * @since 6.2.0 + * * @param {readonly InterventionChangeOutput[]} changes - All projected changes. * @param {InterventionChangeTableQuery} query - Controlled history criteria. * @param {readonly InterventionWorkItemOutput[]} items - Available target labels. + * * @returns {readonly InterventionChangeOutput[]} Matching saved changes. - * @since 6.2.0 */ export function searchSavedChanges( changes: readonly InterventionChangeOutput[], diff --git a/src/app/features/organization/features/interventions/utils/intervention-workspace-projection/testing/intervention-workspace-projection.utils.spec.ts b/src/app/features/organization/features/interventions/utils/intervention-workspace-projection/testing/intervention-workspace-projection.utils.spec.ts index 8f762938e..3ee993c4b 100644 --- a/src/app/features/organization/features/interventions/utils/intervention-workspace-projection/testing/intervention-workspace-projection.utils.spec.ts +++ b/src/app/features/organization/features/interventions/utils/intervention-workspace-projection/testing/intervention-workspace-projection.utils.spec.ts @@ -4,6 +4,7 @@ import type { } from '@features/organization/features/interventions/models'; import { projectInterventionWorkspace, + projectInterventionWorkItemExecutionResult, searchSavedChanges, searchSavedWorkItems, } from '../intervention-workspace-projection.utils'; @@ -127,6 +128,208 @@ describe('saved intervention table projection', () => { expect(projected.workItems[0]?.status).toBe('completed'); }); + it('preserves the actual execution instant while staging an offline result across refreshes', () => { + const item = snapshot.workItems[0]; + if (!item) throw new Error('Expected the saved work-item fixture'); + const fresh: InterventionWorkspaceData = { + ...snapshot, + workItems: [ + { + ...item, + action: 'maintenance', + revision: 9, + updatedAt: '2026-10-07T09:00:00Z', + operationId: 'maintenance-operation', + occurrenceId: 'maintenance-occurrence', + operationKind: 'maintenance', + }, + ], + }; + const pending: InterventionOutboxOperation = { + id: 'execution-update', + interventionId: 'A', + type: 'work-item.update', + payload: { + workItemId: 'work', + revision: 3, + status: 'completed', + executionResult: { + equipmentId: 'pump', + performedAt: '2026-10-05T16:35:00+02:00', + outcome: 'performed', + workPerformed: 'Cleaned and adjusted the pump.', + }, + }, + createdAt: '2026-10-06T09:00:00Z', + status: 'conflict', + }; + + const projected = projectInterventionWorkspace(fresh, [pending], snapshot); + const result = projected.workItems[0]?.executionResult; + expect(result).toEqual({ + ...pending.payload.executionResult, + operationId: 'maintenance-operation', + occurrenceId: 'maintenance-occurrence', + state: 'staged', + validatedAt: null, + history: [], + }); + expect(result).not.toHaveProperty('authorId'); + expect(projected.workItems[0]?.revision).toBe(9); + expect(projected.workItems[0]?.updatedAt).toBe('2026-10-07T09:00:00Z'); + expect(projectInterventionWorkspace(projected, [pending]).workItems).toEqual( + projected.workItems, + ); + expect(fresh.workItems[0]?.executionResult).toBeUndefined(); + }); + + it('retains recorded execution facts when reopening work or attempting to erase a result', () => { + const item = snapshot.workItems[0]; + if (!item) throw new Error('Expected the saved work-item fixture'); + const base: InterventionWorkspaceData = { + ...snapshot, + workItems: [ + { + ...item, + action: 'repair', + status: 'completed', + executionResult: { + equipmentId: 'pump', + performedAt: '2026-10-05T16:35:00+02:00', + outcome: 'successful', + workPerformed: 'Replaced the worn valve.', + authorId: 'recording-member', + operationId: null, + occurrenceId: null, + state: 'staged', + validatedAt: null, + }, + }, + ], + }; + const reopen: InterventionOutboxOperation = { + id: 'reopen-repair', + interventionId: 'A', + type: 'work-item.update', + payload: { workItemId: 'work', status: 'in_progress' }, + createdAt: '2026-10-06T09:00:00Z', + }; + + expect(projectInterventionWorkspace(base, [reopen]).workItems[0]?.executionResult).toEqual( + base.workItems[0]?.executionResult, + ); + expect( + projectInterventionWorkspace(base, [ + { ...reopen, payload: { ...reopen.payload, executionResult: null } }, + ]).workItems[0]?.executionResult, + ).toEqual(base.workItems[0]?.executionResult); + }); + + it('retains a failed attempt when a later offline repair succeeds and projects both only once', () => { + const item = snapshot.workItems[0]; + if (!item) throw new Error('Expected the saved work-item fixture'); + const failed: InterventionOutboxOperation = { + id: 'failed-attempt', + interventionId: 'A', + type: 'work-item.update', + payload: { + workItemId: 'work', + status: 'in_progress', + executionResult: { + equipmentId: 'pump', + performedAt: '2026-10-05T16:35:00+02:00', + outcome: 'failed', + workPerformed: 'Valve is seized; a new part is required.', + }, + }, + createdAt: '2026-10-05T17:00:00Z', + }; + const repaired: InterventionOutboxOperation = { + ...failed, + id: 'successful-attempt', + payload: { + workItemId: 'work', + status: 'completed', + executionResult: { + equipmentId: 'pump', + performedAt: '2026-10-06T09:35:00+02:00', + outcome: 'successful', + workPerformed: 'Replaced the seized valve.', + }, + }, + createdAt: '2026-10-06T10:00:00Z', + }; + const base: InterventionWorkspaceData = { + ...snapshot, + workItems: [{ ...item, action: 'repair' }], + }; + const attempts = [failed, repaired]; + const projected = projectInterventionWorkspace(base, attempts); + expect(projected.workItems[0]?.executionResult).toMatchObject({ + outcome: 'successful', + performedAt: '2026-10-06T09:35:00+02:00', + history: [{ outcome: 'failed', performedAt: '2026-10-05T16:35:00+02:00' }], + }); + expect(projected.workItems[0]?.executionResult?.history?.[0]).not.toHaveProperty('history'); + expect(projectInterventionWorkspace(projected, attempts).workItems).toEqual( + projected.workItems, + ); + }); + + it('keeps confirmed provenance on an identical fact normalized by the API', () => { + const item = snapshot.workItems[0]; + if (!item) throw new Error('Expected the saved work-item fixture'); + const result = { + equipmentId: 'pump', + performedAt: '2026-10-05T14:35:00+00:00', + outcome: 'successful' as const, + workPerformed: 'Replaced the valve.', + authorId: 'recording-member', + operationId: null, + occurrenceId: null, + state: 'staged' as const, + validatedAt: null, + history: [], + }; + const projected = projectInterventionWorkItemExecutionResult( + { ...item, executionResult: result }, + { + equipmentId: 'pump', + performedAt: '2026-10-05T14:35:00.000Z', + outcome: 'successful', + workPerformed: ' Replaced the valve. ', + }, + ); + expect(projected).toBe(result); + expect(projected?.authorId).toBe('recording-member'); + expect(projected?.history).toEqual([]); + }); + + it('keeps an unsuccessful attempt in progress without claiming preventive validation', () => { + const failed: InterventionOutboxOperation = { + id: 'failed-repair', + interventionId: 'A', + type: 'work-item.update', + payload: { + workItemId: 'work', + status: 'in_progress', + executionResult: { + equipmentId: 'pump', + performedAt: '2026-10-05T16:35:00+02:00', + outcome: 'failed', + workPerformed: 'Attempted repair; a replacement valve is required.', + }, + }, + createdAt: '2026-10-06T09:00:00Z', + }; + const projected = projectInterventionWorkspace(snapshot, [failed]); + expect(projected.workItems[0]).toMatchObject({ + status: 'in_progress', + executionResult: { outcome: 'failed', state: 'staged', validatedAt: null }, + }); + expect(projected.intervention.completedWorkItemsCount).toBe(0); + }); + it('ignores a different intervention and does not duplicate an already cached create', () => { const operation = operations[0]; if (!operation) throw new Error('Expected the pending-operation fixture'); @@ -168,6 +371,9 @@ describe('saved intervention table projection', () => { estimatedMinutes: 45, workStartsOn: '2026-09-24', workEndsOn: '2026-09-25', + operationId: 'pump-operation', + occurrenceId: 'pump-occurrence', + operationKind: 'maintenance', }, createdAt: '2026-09-23T10:00:00Z', }; @@ -208,6 +414,10 @@ describe('saved intervention table projection', () => { assignee: '/api/members/marie', target: '/api/equipment/pump', resultResource: '/api/equipment/replacement', + operationId: 'pump-operation', + occurrenceId: 'pump-occurrence', + operationKind: 'maintenance', + executionResult: null, }); expect(projected.workItems[2]).toMatchObject({ target: null, diff --git a/src/app/features/organization/features/interventions/utils/project-intervention-time/project-intervention-time.utils.ts b/src/app/features/organization/features/interventions/utils/project-intervention-time/project-intervention-time.utils.ts index 4834b8da2..d23971b7b 100644 --- a/src/app/features/organization/features/interventions/utils/project-intervention-time/project-intervention-time.utils.ts +++ b/src/app/features/organization/features/interventions/utils/project-intervention-time/project-intervention-time.utils.ts @@ -6,7 +6,6 @@ import type { /** * Function projectInterventionTime - * @function projectInterventionTime * * @description * Overlays queued time intentions by stable identifier. Does not mutate server history, @@ -18,7 +17,10 @@ import type { * @param {readonly InterventionTimeEntry[]} entries - Last authorized server snapshot. * @param {readonly InterventionOutboxOperation[]} operations - Ordered local operations. * @param {string} workItemId - Selected journal task. + * * @returns {readonly InterventionTimeEntryView[]} Projected journal rows. + * + * @function projectInterventionTime */ export function projectInterventionTime( entries: readonly InterventionTimeEntry[], @@ -67,6 +69,8 @@ export function projectInterventionTime( createdAt: existing?.createdAt ?? operation.createdAt, updatedAt: operation.createdAt, versions: existing?.versions ?? [], + totalVersions: existing?.totalVersions ?? existing?.revision ?? 0, + nextBeforeRevision: existing?.nextBeforeRevision ?? null, syncStatus, }); } diff --git a/src/app/features/organization/features/interventions/utils/project-intervention-time/testing/project-intervention-time.utils.spec.ts b/src/app/features/organization/features/interventions/utils/project-intervention-time/testing/project-intervention-time.utils.spec.ts index 6915290c7..0ba7351f3 100644 --- a/src/app/features/organization/features/interventions/utils/project-intervention-time/testing/project-intervention-time.utils.spec.ts +++ b/src/app/features/organization/features/interventions/utils/project-intervention-time/testing/project-intervention-time.utils.spec.ts @@ -11,6 +11,8 @@ const input = { note: 'Field work', }; const entry: InterventionTimeEntry = { + totalVersions: 1, + nextBeforeRevision: null, ...input, workItemId: 'task', revision: 1, diff --git a/src/app/features/organization/features/inventory/FEATURE.md b/src/app/features/organization/features/inventory/FEATURE.md new file mode 100644 index 000000000..a84446deb --- /dev/null +++ b/src/app/features/organization/features/inventory/FEATURE.md @@ -0,0 +1,57 @@ +# Inventory Feature + +Owns quantitative parts, consumables, warehouses, balances, declarations and immutable stock movements. +Individual reserve equipment remains Equipment-owned and is never counted as a quantitative article. + +## Public entry points + +| Entry point | Consumers | +| ------------------------------------------- | -------------------------------------------------------------------------- | +| `models` | `organization/features/procurement`, `organization/features/interventions` | +| `data-access` | `organization/features/procurement`, `organization/features/interventions` | +| `ui/components/inventory-part-picker` | `organization/features/procurement` | +| `ui/components/inventory-warehouse-picker` | `organization/features/procurement` | +| `ui/components/inventory-consumption-panel` | `organization/features/interventions` | + +## Entry points + +- `INVENTORY_ROUTES`: organization-scoped stock, parts, warehouses, declarations and movement history. +- `models`: canonical transport contracts and exact decimal string quantities. +- `data-access`: `InventoryService` quantity-only stock reads, references and stable-UUID commands. +- `ui/components/inventory-part-picker` and `inventory-warehouse-picker`: published Signal Forms + widgets for Procurement and Inventory consumers. They own scoped browser-only server search, + pagination and selected-record hydration; archived records remain readable but cannot be newly picked. +- `ui/components/inventory-consumption-panel`: presentation contract for Interventions. The parent + owns durable local persistence, authorized cached references, transmission, receipt correlation and replay. + +Procurement and Interventions may consume these documented UI barrels, `models` and `data-access`. +Inventory consumes the Organization member-access port and Auth's public `AUTH_SESSION_PORT` +generation for local journal ownership; it never reads or persists bearer tokens. + +Returns and stock corrections are retained in an account-bound IndexedDB journal before transmission. +An uncertain command stays available for explicit replay with its original UUID and body, including +after navigation. Old session continuations cannot transmit or acknowledge a new session's journal. + +Inventory never calculates quantities or money with floating-point numbers. Ordinary balance and +movement contracts contain quantities only; dedicated financial projections and permissions protect +internal values. New movements compensate factual corrections; no history is overwritten. + +Reads and administration are browser-only, with no private TransferState handoff. Organization changes +cancel obsolete reads; accepted writes keep their originating scope and repeated clicks cannot cancel them. +Declarations keep one immutable client operation UUID. A received pending declaration is preserved, +without implying a partial stock debit, confirmed stock, completed work or a published dossier. +A publication blocked by received declarations must be resolved through the owning Intervention workflow. + +The consumption panel emits intent without calling transports. The parent acknowledges successful +local persistence by matching `acceptedOperationId` or the represented local intent. Server declaration +IDs are distinct from operation UUIDs; server acknowledgment is correlated by the parent command owner. +Local queued, sending and failed states stay separate from server received-pending and confirmed states. + +Administration requires the dedicated Inventory permissions. Consumption and returns additionally +require intervention execution; reconciliation requires inventory management and execution; stock +corrections additionally require maintenance-cost management. Financial values never enter ordinary +client reports. Equipment maintenance, procurement, commercial billing and cost dashboards stay with +those owning features. + +References have permanent codes and part kinds. The current API exposes no revision precondition for +reference edits; the UI preserves failed drafts and never invents a concurrency token. diff --git a/src/app/features/organization/features/inventory/data-access/index.ts b/src/app/features/organization/features/inventory/data-access/index.ts new file mode 100644 index 000000000..314d985e6 --- /dev/null +++ b/src/app/features/organization/features/inventory/data-access/index.ts @@ -0,0 +1,2 @@ +export { InventoryService } from './services/inventory/inventory.service'; +export { InventoryCommandRepository } from './services/inventory-offline/inventory-command.repository'; diff --git a/src/app/features/organization/features/inventory/data-access/services/inventory-offline/inventory-command.repository.ts b/src/app/features/organization/features/inventory/data-access/services/inventory-offline/inventory-command.repository.ts new file mode 100644 index 000000000..8fccb8b57 --- /dev/null +++ b/src/app/features/organization/features/inventory/data-access/services/inventory-offline/inventory-command.repository.ts @@ -0,0 +1,205 @@ +import { inject, Service } from '@angular/core'; +import { IndexedDbService, type IndexedDbSchema } from '@core/indexed-db'; +import { AUTH_SESSION_PORT, type AuthSessionPort } from '@features/auth/ports'; +import type { InventoryPhysicalCommand } from '@features/organization/features/inventory/models'; +import { + ORGANIZATION_MEMBER_ACCESS_PORT, + type OrganizationMemberAccessPort, +} from '@features/organization/ports/organization-member-access'; + +/** + * Class InventoryCommandRepository + * + * @description + * A small account-bound journal preserves physical commands before transmission. + */ +@Service() +export class InventoryCommandRepository extends IndexedDbService { + /** + * Property schema + * @readonly + * + * @description + * Independent database contains immutable unresolved stock commands. + * + * @access protected + * @since unreleased + * + * @type {IndexedDbSchema} + */ + protected readonly schema: IndexedDbSchema = { + name: 'fireguard-inventory', + version: 1, + storeNames: ['metadata', 'commands'], + ownerStoreName: 'metadata', + }; + /** + * Property member + * @readonly + * + * @description + * Published account identity prevents cross-session replay. + * + * @access private + * @since unreleased + * + * @type {OrganizationMemberAccessPort} + */ + private readonly member: OrganizationMemberAccessPort = inject(ORGANIZATION_MEMBER_ACCESS_PORT); + /** + * Property session + * @readonly + * + * @description + * Local generation fences clearing and replacement, without persisting tokens. + * + * @access private + * @since unreleased + * + * @type {AuthSessionPort} + */ + private readonly session: AuthSessionPort = inject(AUTH_SESSION_PORT); + /** + * Property writes + * + * @description + * Serializes local identity checks before immutable journal writes. + * + * @access private + * @since unreleased + * + * @type {Promise} + */ + private writes: Promise = Promise.resolve(); + + /** + * Method sessionRevision + * + * @description + * Captures the current session generation before asynchronous work. + * + * @access public + * @since unreleased + * + * @returns {number} Local session generation. + */ + public sessionRevision(): number { + return this.session.sessionRevision(); + } + + /** + * Method isCurrent + * + * @description + * Tests active membership without silently rebinding an old operation. + * + * @access public + * @since unreleased + * + * @param {string} userId - Captured account. + * @param {string} organizationId - Captured organization. + * @param {number} revision - Captured command context. + * + * @returns {boolean} Whether the actor still owns the active organization. + */ + public isCurrent( + userId: string, + organizationId: string, + revision: number = this.sessionRevision(), + ): boolean { + const profile = this.member.profile(); + return ( + this.browser && + this.session.isAuthenticated() && + this.session.sessionRevision() === revision && + !!profile?.isActive && + profile.userId === userId && + profile.organizationId === organizationId + ); + } + + /** + * Method readPending + * + * @description + * Returns only the active user's organization journal; never auto-sends records. + * + * @access public + * @since unreleased + * + * @param {string} userId - Captured account. + * @param {string} organizationId - Captured organization. + * + * @returns {Promise} Pending immutable commands. + */ + public async readPending( + userId: string, + organizationId: string, + ): Promise { + const revision = this.sessionRevision(); + if (!this.isCurrent(userId, organizationId, revision)) return []; + await this.ensureOwnerBound(userId); + if (!this.isCurrent(userId, organizationId, revision)) return []; + const commands = await this.getAll('commands'); + return this.isCurrent(userId, organizationId, revision) + ? commands.filter( + (command) => command.userId === userId && command.organizationId === organizationId, + ) + : []; + } + + /** + * Method retain + * + * @description + * Commits one immutable intention before any network side effect. + * + * @access public + * @since unreleased + * + * @param {InventoryPhysicalCommand} command - Stable UUID and body. + * + * @returns {Promise} Durable acceptance or explicit local-storage failure. + */ + public retain(command: InventoryPhysicalCommand): Promise { + const revision = this.sessionRevision(); + const current = () => this.isCurrent(command.userId, command.organizationId, revision); + const retained = this.writes + .catch(() => undefined) + .then(async () => { + if (!current()) throw new Error('Inventory command ownership changed.'); + await this.ensureOwnerBound(command.userId); + if (!current()) throw new Error('Inventory command ownership changed.'); + const id = command.input.clientOperationId; + const previous = await this.get('commands', id); + if (previous && JSON.stringify(previous) !== JSON.stringify(command)) + throw new Error('Inventory command identity already belongs to a different intention.'); + await this.put('commands', id, command, current); + }); + this.writes = retained; + return retained; + } + + /** + * Method acknowledge + * + * @description + * Removes only a confirmed command in its original active account. + * + * @access public + * @since unreleased + * + * @param {InventoryPhysicalCommand} command - Confirmed physical intention. + * @param {number} revision - Captured command context. + * + * @returns {Promise} Journal acknowledgment. + */ + public async acknowledge( + command: InventoryPhysicalCommand, + revision: number = this.sessionRevision(), + ): Promise { + const current = () => this.isCurrent(command.userId, command.organizationId, revision); + if (!current()) return; + await this.remove('commands', command.input.clientOperationId, current); + } +} diff --git a/src/app/features/organization/features/inventory/data-access/services/inventory-offline/testing/inventory-command.repository.spec.ts b/src/app/features/organization/features/inventory/data-access/services/inventory-offline/testing/inventory-command.repository.spec.ts new file mode 100644 index 000000000..6ef46e852 --- /dev/null +++ b/src/app/features/organization/features/inventory/data-access/services/inventory-offline/testing/inventory-command.repository.spec.ts @@ -0,0 +1,337 @@ +import { PLATFORM_ID, signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { AUTH_SESSION_PORT } from '@features/auth/ports'; +import type { InventoryPhysicalCommand } from '@features/organization/features/inventory/models'; +import type { CurrentOrganizationMemberProfileOutput } from '@features/organization/models'; +import { ORGANIZATION_MEMBER_ACCESS_PORT } from '@features/organization/ports/organization-member-access'; +import { InventoryCommandRepository } from '../inventory-command.repository'; + +describe('InventoryCommandRepository', () => { + const initialProfile: CurrentOrganizationMemberProfileOutput = { + '@id': '/me', + '@type': 'OrganizationMember', + id: 'member-a', + userId: 'user-a', + organizationId: 'org-a', + isActive: true, + joinedAt: '2026-10-06T10:00:00Z', + roles: [], + permissions: [], + }; + const command: InventoryPhysicalCommand = { + kind: 'correction', + userId: 'user-a', + organizationId: 'org-a', + input: { + clientOperationId: 'stable-operation', + partId: 'part', + warehouseId: 'warehouse', + quantity: '999999999999999999.123456', + reason: 'Physical count', + }, + }; + + function setup(platform = 'browser') { + const profile = signal(initialProfile); + const revision = signal(1); + const authenticated = signal(true); + TestBed.configureTestingModule({ + providers: [ + InventoryCommandRepository, + { provide: PLATFORM_ID, useValue: platform }, + { provide: ORGANIZATION_MEMBER_ACCESS_PORT, useValue: { profile } }, + { + provide: AUTH_SESSION_PORT, + useValue: { sessionRevision: revision, isAuthenticated: authenticated }, + }, + ], + }); + const repository = TestBed.inject(InventoryCommandRepository); + const entries = new Map([['metadata:ownerUserId', 'user-a']]); + const get = vi + .spyOn(repository, 'get') + .mockImplementation(async (storeName: string, key: string): Promise => { + const value = entries.get(`${storeName}:${key}`); + return value === undefined ? null : (structuredClone(value) as T); + }); + const getAll = vi + .spyOn(repository, 'getAll') + .mockImplementation(async (storeName: string): Promise => + [...entries.entries()] + .filter(([key]) => key.startsWith(storeName + ':')) + .map(([, value]) => structuredClone(value) as T), + ); + const put = vi + .spyOn(repository, 'put') + .mockImplementation( + async ( + storeName: string, + key: string, + value: unknown, + current?: () => boolean, + ): Promise => { + if (current && !current()) + throw new DOMException('Offline operation ownership changed.', 'AbortError'); + entries.set(`${storeName}:${key}`, structuredClone(value)); + }, + ); + const remove = vi + .spyOn(repository, 'remove') + .mockImplementation( + async (storeName: string, key: string, current?: () => boolean): Promise => { + if (current && !current()) + throw new DOMException('Offline operation ownership changed.', 'AbortError'); + entries.delete(`${storeName}:${key}`); + }, + ); + const clearAll = vi + .spyOn(repository, 'clearAll') + .mockImplementation(async (): Promise => { + entries.clear(); + }); + return { + repository, + profile, + revision, + authenticated, + entries, + get, + getAll, + put, + remove, + clearAll, + }; + } + + it('retains exact physical facts and retries the same intention without replacing its UUID', async () => { + const { repository, entries } = setup(); + await repository.retain(command); + await repository.retain({ ...command, input: { ...command.input } }); + const pending = await repository.readPending('user-a', 'org-a'); + expect(pending).toEqual([command]); + expect(entries.get('commands:stable-operation')).toEqual(command); + expect(pending[0]?.input.quantity).toBe('999999999999999999.123456'); + expect([...entries.keys()].filter((key) => key.startsWith('commands:'))).toEqual([ + 'commands:stable-operation', + ]); + }); + + it('rejects a retained UUID reused for different physical facts and preserves the original', async () => { + const { repository, entries } = setup(); + await repository.retain(command); + await expect( + repository.retain({ ...command, input: { ...command.input, quantity: '2.000000' } }), + ).rejects.toThrow('different intention'); + expect(entries.get('commands:stable-operation')).toEqual(command); + }); + + it('serializes concurrent competing intentions sharing one UUID', async () => { + const { repository, entries } = setup(); + const first = repository.retain(command); + const competing = repository.retain({ + ...command, + input: { ...command.input, quantity: '2.000000' }, + }); + const outcomes = await Promise.allSettled([first, competing]); + expect(outcomes[0]?.status).toBe('fulfilled'); + expect(outcomes[1]?.status).toBe('rejected'); + expect(entries.get('commands:stable-operation')).toEqual(command); + }); + + it('resolves durable acceptance only after the storage write commits', async () => { + const { repository, entries, put } = setup(); + let commit!: () => void; + const gate = new Promise((resolve) => { + commit = resolve; + }); + put.mockImplementation(async (storeName, key, value, current) => { + if (storeName === 'commands') await gate; + if (current && !current()) + throw new DOMException('Offline operation ownership changed.', 'AbortError'); + entries.set(`${storeName}:${key}`, structuredClone(value)); + }); + let accepted = false; + const retention = repository.retain(command).then(() => { + accepted = true; + }); + await vi.waitFor(() => + expect(put).toHaveBeenCalledWith( + 'commands', + 'stable-operation', + command, + expect.any(Function), + ), + ); + expect(accepted).toBe(false); + expect(entries.has('commands:stable-operation')).toBe(false); + commit(); + await retention; + expect(accepted).toBe(true); + expect(entries.get('commands:stable-operation')).toEqual(command); + }); + + it('propagates storage failure without claiming durable acceptance', async () => { + const { repository, entries, put } = setup(); + put.mockImplementation(async (storeName, key, value) => { + if (storeName === 'commands') throw new DOMException('Quota exceeded.', 'QuotaExceededError'); + entries.set(`${storeName}:${key}`, structuredClone(value)); + }); + await expect(repository.retain(command)).rejects.toMatchObject({ name: 'QuotaExceededError' }); + expect(entries.has('commands:stable-operation')).toBe(false); + }); + + it('filters by account and organization and preserves other-organization facts', async () => { + const { repository, profile, entries } = setup(); + await repository.retain(command); + const other: InventoryPhysicalCommand = { + ...command, + organizationId: 'org-b', + input: { ...command.input, clientOperationId: 'other-organization' }, + }; + entries.set('commands:other-organization', other); + entries.set('commands:foreign-account', { + ...command, + userId: 'user-b', + input: { ...command.input, clientOperationId: 'foreign-account' }, + }); + expect(await repository.readPending('user-a', 'org-a')).toEqual([command]); + expect(await repository.readPending('user-a', 'org-b')).toEqual([]); + profile.set({ ...initialProfile, organizationId: 'org-b' }); + expect(await repository.readPending('user-a', 'org-b')).toEqual([other]); + expect(entries.get('commands:stable-operation')).toEqual(command); + }); + + it('rebinds storage for a new account before exposing its command journal', async () => { + const { repository, profile, entries, clearAll } = setup(); + await repository.retain(command); + profile.set({ ...initialProfile, userId: 'user-b' }); + expect(await repository.readPending('user-b', 'org-a')).toEqual([]); + expect(clearAll).toHaveBeenCalledTimes(1); + expect(entries.has('commands:stable-operation')).toBe(false); + expect(entries.get('metadata:ownerUserId')).toBe('user-b'); + }); + + it('fences ownership changes while account binding is pending', async () => { + const { repository, profile, put } = setup(); + let release!: () => void; + vi.spyOn(repository, 'ensureOwnerBound').mockReturnValue( + new Promise((resolve) => { + release = resolve; + }), + ); + const retention = repository.retain(command); + const rejected = expect(retention).rejects.toThrow(/ownership changed/i); + profile.set({ ...initialProfile, userId: 'user-b' }); + release(); + await rejected; + expect(put).not.toHaveBeenCalled(); + }); + + it('rejects an old pending retain after logout and same-account return', async () => { + const { repository, profile, revision, authenticated, put } = setup(); + let release!: () => void; + vi.spyOn(repository, 'ensureOwnerBound').mockReturnValue( + new Promise((resolve) => { + release = resolve; + }), + ); + const retention = repository.retain(command); + const rejected = expect(retention).rejects.toThrow(/ownership changed/i); + authenticated.set(false); + profile.set(null); + revision.set(2); + authenticated.set(true); + profile.set(initialProfile); + revision.set(3); + release(); + await rejected; + expect(put).not.toHaveBeenCalled(); + }); + + it('discards an old read result after scope changes during the IndexedDB read', async () => { + const { repository, profile, getAll } = setup(); + let release!: (commands: readonly InventoryPhysicalCommand[]) => void; + getAll.mockReturnValue( + new Promise((resolve) => { + release = resolve; + }), + ); + const reading = repository.readPending('user-a', 'org-a'); + await vi.waitFor(() => expect(getAll).toHaveBeenCalledWith('commands')); + profile.set({ ...initialProfile, organizationId: 'org-b' }); + release([command]); + expect(await reading).toEqual([]); + }); + + it('keeps acknowledged facts when the original owner is no longer current', async () => { + const { repository, profile, entries, remove } = setup(); + await repository.retain(command); + profile.set({ ...initialProfile, organizationId: 'org-b' }); + await repository.acknowledge(command); + expect(remove).not.toHaveBeenCalled(); + expect(entries.get('commands:stable-operation')).toEqual(command); + profile.set(initialProfile); + await repository.acknowledge(command); + expect(entries.has('commands:stable-operation')).toBe(false); + }); + + it('discards a pending read from a previous session even when the same account returns', async () => { + const { repository, revision, getAll } = setup(); + let release!: (commands: readonly InventoryPhysicalCommand[]) => void; + getAll.mockReturnValue( + new Promise((resolve) => { + release = resolve; + }), + ); + const reading = repository.readPending('user-a', 'org-a'); + await vi.waitFor(() => expect(getAll).toHaveBeenCalledWith('commands')); + revision.set(3); + release([command]); + expect(await reading).toEqual([]); + }); + + it('keeps a confirmed command when its acknowledgment belongs to an old session', async () => { + const { repository, revision, entries, remove } = setup(); + await repository.retain(command); + const originalRevision = repository.sessionRevision(); + revision.set(3); + expect(repository.isCurrent('user-a', 'org-a', originalRevision)).toBe(false); + await repository.acknowledge(command, originalRevision); + expect(remove).not.toHaveBeenCalled(); + expect(entries.get('commands:stable-operation')).toEqual(command); + expect(await repository.readPending('user-a', 'org-a')).toEqual([command]); + await repository.acknowledge(command, repository.sessionRevision()); + expect(entries.has('commands:stable-operation')).toBe(false); + }); + + it('recovers the serialized journal after a rejected conflicting intention', async () => { + const { repository } = setup(); + await repository.retain(command); + await expect( + repository.retain({ ...command, input: { ...command.input, reason: 'Different facts' } }), + ).rejects.toThrow('different intention'); + const next: InventoryPhysicalCommand = { + ...command, + input: { ...command.input, clientOperationId: 'next-operation' }, + }; + await repository.retain(next); + expect(await repository.readPending('user-a', 'org-a')).toEqual([command, next]); + }); + + it('refuses authenticated journal access for an inactive organization member', async () => { + const { repository, profile, get, put } = setup(); + profile.set({ ...initialProfile, isActive: false }); + expect(await repository.readPending('user-a', 'org-a')).toEqual([]); + await expect(repository.retain(command)).rejects.toThrow(/ownership changed/i); + expect(get).not.toHaveBeenCalled(); + expect(put).not.toHaveBeenCalled(); + }); + + it('never opens or mutates the journal during SSR', async () => { + const { repository, get, put } = setup('server'); + expect(await repository.readPending('user-a', 'org-a')).toEqual([]); + await expect(repository.retain(command)).rejects.toThrow(/ownership changed/i); + expect(get).not.toHaveBeenCalled(); + expect(put).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/features/organization/features/inventory/data-access/services/inventory/inventory.service.ts b/src/app/features/organization/features/inventory/data-access/services/inventory/inventory.service.ts new file mode 100644 index 000000000..d1a7ff4dc --- /dev/null +++ b/src/app/features/organization/features/inventory/data-access/services/inventory/inventory.service.ts @@ -0,0 +1,439 @@ +import { Service } from '@angular/core'; +import type { Observable } from 'rxjs'; +import { HydraApiService, type RequestOptions } from '@core/api'; +import type { HydraCollection } from '@core/api/models'; +import type { + InventoryPartOutput, + CreateInventoryPartInput, + UpdateInventoryPartInput, + InventoryWarehouseOutput, + CreateInventoryWarehouseInput, + UpdateInventoryWarehouseInput, + InventoryBalanceOutput, + InventoryMovementOutput, + InventoryConsumptionOutput, + DeclareInventoryConsumptionInput, + ReturnInventoryConsumptionInput, + CorrectInventoryStockInput, +} from '@features/organization/features/inventory/models'; + +/** + * Class InventoryService + * @class InventoryService + * + * @description + * Organization-scoped Inventory transport preserves exact string quantities and stable command + * UUIDs. + */ +@Service() +export class InventoryService extends HydraApiService { + //#region Methods + /** + * Method listParts + * @method listParts + * + * @description + * Lists quantitative references with server search, archive filtering and pagination. + * + * @access public + * @since unreleased + * + * @param {string} organizationId - Organization owning the inventory. + * @param {RequestOptions | undefined} options - Query filters and server page. + * + * @returns {Observable>} Server-owned page and exact total. + */ + public listParts( + organizationId: string, + options?: RequestOptions, + ): Observable> { + return this.getCollection( + `/api/organizations/${organizationId}/inventory-parts`, + options, + ); + } + + /** + * Method listWarehouses + * @method listWarehouses + * + * @description + * Lists stock warehouses with server search, archive filtering and pagination. + * + * @access public + * @since unreleased + * + * @param {string} organizationId - Organization owning the inventory. + * @param {RequestOptions | undefined} options - Query filters and server page. + * + * @returns {Observable>} Server-owned page and exact + * total. + */ + public listWarehouses( + organizationId: string, + options?: RequestOptions, + ): Observable> { + return this.getCollection( + `/api/organizations/${organizationId}/inventory-warehouses`, + options, + ); + } + + /** + * Method listBalances + * @method listBalances + * + * @description + * Reads quantity-only balances filtered by part or warehouse. + * + * @access public + * @since unreleased + * + * @param {string} organizationId - Organization owning the inventory. + * @param {RequestOptions | undefined} options - Query filters and server page. + * + * @returns {Observable>} Server-owned page and exact + * total. + */ + public listBalances( + organizationId: string, + options?: RequestOptions, + ): Observable> { + return this.getCollection( + `/api/organizations/${organizationId}/inventory-balances`, + options, + ); + } + + /** + * Method listConsumptions + * @method listConsumptions + * + * @description + * Reads received declarations independently of local queue and work completion. + * + * @access public + * @since unreleased + * + * @param {string} organizationId - Organization owning the inventory. + * @param {RequestOptions | undefined} options - Query filters and server page. + * + * @returns {Observable>} Server-owned page and exact + * total. + */ + public listConsumptions( + organizationId: string, + options?: RequestOptions, + ): Observable> { + return this.getCollection( + `/api/organizations/${organizationId}/inventory-consumptions`, + options, + ); + } + + /** + * Method listMovements + * @method listMovements + * + * @description + * Reads immutable quantity-only stock movement history. + * + * @access public + * @since unreleased + * + * @param {string} organizationId - Organization owning the inventory. + * @param {RequestOptions | undefined} options - Query filters and server page. + * + * @returns {Observable>} Server-owned page and exact + * total. + */ + public listMovements( + organizationId: string, + options?: RequestOptions, + ): Observable> { + return this.getCollection( + `/api/organizations/${organizationId}/inventory-movements`, + options, + ); + } + + /** + * Method readPart + * @method readPart + * + * @description + * Hydrates a retained identity independently of directory search and pagination. + * + * @access public + * @since unreleased + * + * @param {string} organizationId - Organization owning the inventory. + * @param {string} id - Stable server resource identity. + * + * @returns {Observable} Current canonical resource. + */ + public readPart(organizationId: string, id: string): Observable { + return this.getOne( + `/api/organizations/${organizationId}/inventory-parts/${encodeURIComponent(id)}`, + ); + } + + /** + * Method readWarehouse + * @method readWarehouse + * + * @description + * Hydrates a retained identity independently of directory search and pagination. + * + * @access public + * @since unreleased + * + * @param {string} organizationId - Organization owning the inventory. + * @param {string} id - Stable server resource identity. + * + * @returns {Observable} Current canonical resource. + */ + public readWarehouse(organizationId: string, id: string): Observable { + return this.getOne( + `/api/organizations/${organizationId}/inventory-warehouses/${encodeURIComponent(id)}`, + ); + } + + /** + * Method readConsumption + * @method readConsumption + * + * @description + * Hydrates a retained identity independently of directory search and pagination. + * + * @access public + * @since unreleased + * + * @param {string} organizationId - Organization owning the inventory. + * @param {string} id - Stable server resource identity. + * + * @returns {Observable} Current canonical resource. + */ + public readConsumption( + organizationId: string, + id: string, + ): Observable { + return this.getOne( + `/api/organizations/${organizationId}/inventory-consumptions/${encodeURIComponent(id)}`, + ); + } + + /** + * Method createPart + * @method createPart + * + * @description + * Creates a part or consumable reference with permanent code and category. + * + * @access public + * @since unreleased + * + * @param {string} organizationId - Organization owning the inventory. + * @param {CreateInventoryPartInput} input - Validated transport input preserved through retries. + * + * @returns {Observable} Confirmed server resource or declaration receipt. + */ + public createPart( + organizationId: string, + input: CreateInventoryPartInput, + ): Observable { + return this.post( + `/api/organizations/${organizationId}/inventory-parts`, + input, + ); + } + + /** + * Method createWarehouse + * @method createWarehouse + * + * @description + * Creates a warehouse with a permanent code. + * + * @access public + * @since unreleased + * + * @param {string} organizationId - Organization owning the inventory. + * @param {CreateInventoryWarehouseInput} input - Validated transport input preserved through + * retries. + * + * @returns {Observable} Confirmed server resource or declaration + * receipt. + */ + public createWarehouse( + organizationId: string, + input: CreateInventoryWarehouseInput, + ): Observable { + return this.post( + `/api/organizations/${organizationId}/inventory-warehouses`, + input, + ); + } + + /** + * Method declareConsumption + * @method declareConsumption + * + * @description + * Records a physical declaration with the immutable client operation UUID. + * + * @access public + * @since unreleased + * + * @param {string} organizationId - Organization owning the inventory. + * @param {DeclareInventoryConsumptionInput} input - Validated transport input preserved through + * retries. + * + * @returns {Observable} Confirmed server resource or declaration + * receipt. + */ + public declareConsumption( + organizationId: string, + input: DeclareInventoryConsumptionInput, + ): Observable { + return this.post( + `/api/organizations/${organizationId}/inventory-consumptions`, + input, + ); + } + + /** + * Method returnConsumption + * @method returnConsumption + * + * @description + * Records a motivated compensating return using one stable command UUID. + * + * @access public + * @since unreleased + * + * @param {string} organizationId - Organization owning the inventory. + * @param {ReturnInventoryConsumptionInput} input - Validated transport input preserved through + * retries. + * + * @returns {Observable} Confirmed server resource or declaration + * receipt. + */ + public returnConsumption( + organizationId: string, + input: ReturnInventoryConsumptionInput, + ): Observable { + return this.post( + `/api/organizations/${organizationId}/inventory-returns`, + input, + ); + } + + /** + * Method correctStock + * @method correctStock + * + * @description + * Records a motivated signed stock correction using dedicated financial authorization. + * + * @access public + * @since unreleased + * + * @param {string} organizationId - Organization owning the inventory. + * @param {CorrectInventoryStockInput} input - Validated transport input preserved through + * retries. + * + * @returns {Observable} Confirmed server resource or declaration + * receipt. + */ + public correctStock( + organizationId: string, + input: CorrectInventoryStockInput, + ): Observable { + return this.post( + `/api/organizations/${organizationId}/inventory-corrections`, + input, + ); + } + + /** + * Method updatePart + * @method updatePart + * + * @description + * Changes mutable descriptions or archive state without replacing permanent reference codes. + * + * @access public + * @since unreleased + * + * @param {string} organizationId - Organization owning the inventory. + * @param {string} id - Stable server reference identity. + * @param {UpdateInventoryPartInput} input - Explicit mutable fields. + * + * @returns {Observable} Current server reference. + */ + public updatePart( + organizationId: string, + id: string, + input: UpdateInventoryPartInput, + ): Observable { + return this.patch( + `/api/organizations/${organizationId}/inventory-parts/${encodeURIComponent(id)}`, + input, + ); + } + + /** + * Method updateWarehouse + * @method updateWarehouse + * + * @description + * Changes mutable descriptions or archive state without replacing permanent reference codes. + * + * @access public + * @since unreleased + * + * @param {string} organizationId - Organization owning the inventory. + * @param {string} id - Stable server reference identity. + * @param {UpdateInventoryWarehouseInput} input - Explicit mutable fields. + * + * @returns {Observable} Current server reference. + */ + public updateWarehouse( + organizationId: string, + id: string, + input: UpdateInventoryWarehouseInput, + ): Observable { + return this.patch( + `/api/organizations/${organizationId}/inventory-warehouses/${encodeURIComponent(id)}`, + input, + ); + } + + /** + * Method reconcileConsumption + * @method reconcileConsumption + * + * @description + * Re-evaluates the full declaration against stock without changing the physical fact or debiting + * partially. + * + * @access public + * @since unreleased + * + * @param {string} organizationId - Organization owning the declaration. + * @param {string} id - Current declaration identity. + * + * @returns {Observable} Current confirmed or received-pending state. + */ + public reconcileConsumption( + organizationId: string, + id: string, + ): Observable { + return this.post, InventoryConsumptionOutput>( + `/api/organizations/${organizationId}/inventory-consumptions/${encodeURIComponent(id)}/reconcile`, + {}, + ); + } + //#endregion +} diff --git a/src/app/features/organization/features/inventory/data-access/services/inventory/testing/inventory.service.spec.ts b/src/app/features/organization/features/inventory/data-access/services/inventory/testing/inventory.service.spec.ts new file mode 100644 index 000000000..1797aef78 --- /dev/null +++ b/src/app/features/organization/features/inventory/data-access/services/inventory/testing/inventory.service.spec.ts @@ -0,0 +1,251 @@ +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { ENV_CONFIG } from '@core/config/environment/env.token'; +import type { + DeclareInventoryConsumptionInput, + InventoryConsumptionOutput, +} from '@features/organization/features/inventory/models'; +import { InventoryService } from '../inventory.service'; + +describe('InventoryService', () => { + let service: InventoryService; + let http: HttpTestingController; + const base = 'https://api.test/api/organizations/org'; + const declaration: DeclareInventoryConsumptionInput = { + clientOperationId: '77306066-a66e-44e4-909b-100ba3ec79d4', + partId: 'part', + warehouseId: 'warehouse', + quantity: '999999999999999999.123456', + interventionId: 'intervention', + workItemId: 'work', + equipmentId: 'equipment', + occurredAt: '2026-10-06T10:00:00.123Z', + }; + const receipt: InventoryConsumptionOutput = { + '@id': base + '/inventory-consumptions/server-id', + '@type': 'InventoryConsumption', + id: 'server-id', + partId: declaration.partId, + warehouseId: declaration.warehouseId, + quantity: declaration.quantity, + interventionId: declaration.interventionId, + actorId: 'actor', + occurredAt: declaration.occurredAt, + status: 'received_pending', + reason: 'insufficient_stock', + late: false, + replayed: false, + }; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + InventoryService, + { provide: ENV_CONFIG, useValue: { apiUrl: 'https://api.test' } }, + ], + }); + service = TestBed.inject(InventoryService); + http = TestBed.inject(HttpTestingController); + }); + afterEach(() => http.verify()); + + it('searches and paginates active part and warehouse directories on the server', () => { + service + .listParts('org', { page: 3, itemsPerPage: 50, search: 'seal', params: { archived: false } }) + .subscribe(); + const parts = http.expectOne((request) => request.url === base + '/inventory-parts'); + expect(parts.request.method).toBe('GET'); + expect(parts.request.params.get('page')).toBe('3'); + expect(parts.request.params.get('itemsPerPage')).toBe('50'); + expect(parts.request.params.get('search')).toBe('seal'); + expect(parts.request.params.get('archived')).toBe('false'); + expect(parts.request.withCredentials).toBe(true); + parts.flush({ member: [], totalItems: 0 }); + service + .listWarehouses('org', { + page: 2, + itemsPerPage: 50, + search: 'van', + params: { archived: true }, + }) + .subscribe(); + const warehouses = http.expectOne((request) => request.url === base + '/inventory-warehouses'); + expect(warehouses.request.params.get('search')).toBe('van'); + expect(warehouses.request.params.get('archived')).toBe('true'); + expect(warehouses.request.params.get('page')).toBe('2'); + warehouses.flush({ member: [], totalItems: 0 }); + }); + + it('hydrates retained references using item GET without adding an active-only filter', () => { + service.readPart('org', 'part/old').subscribe(); + const part = http.expectOne(base + '/inventory-parts/part%2Fold'); + expect(part.request.method).toBe('GET'); + expect(part.request.params.has('archived')).toBe(false); + part.flush({ '@id': '/old', '@type': 'InventoryPart', archived: true }); + service.readWarehouse('org', 'warehouse/old').subscribe(); + const warehouse = http.expectOne(base + '/inventory-warehouses/warehouse%2Fold'); + expect(warehouse.request.method).toBe('GET'); + warehouse.flush({ '@id': '/old', '@type': 'InventoryWarehouse', archived: true }); + service.readConsumption('org', 'declaration/old').subscribe(); + const fact = http.expectOne(base + '/inventory-consumptions/declaration%2Fold'); + expect(fact.request.method).toBe('GET'); + fact.flush(receipt); + }); + + it('reads stock and history with exact business filters and without financial expansion', () => { + service + .listBalances('org', { params: { partId: 'part', warehouseId: 'warehouse' } }) + .subscribe(); + const balance = http.expectOne((request) => request.url === base + '/inventory-balances'); + expect(balance.request.params.get('partId')).toBe('part'); + expect(balance.request.params.get('warehouseId')).toBe('warehouse'); + expect(balance.request.params.has('valuation')).toBe(false); + balance.flush({ member: [], totalItems: 0 }); + service + .listMovements('org', { page: 2, params: { partId: 'part', interventionId: 'intervention' } }) + .subscribe(); + const movement = http.expectOne((request) => request.url === base + '/inventory-movements'); + expect(movement.request.params.get('interventionId')).toBe('intervention'); + expect(movement.request.params.get('page')).toBe('2'); + movement.flush({ member: [], totalItems: 0 }); + service + .listConsumptions('org', { + params: { interventionId: 'intervention', status: 'received_pending' }, + }) + .subscribe(); + const consumptions = http.expectOne( + (request) => request.url === base + '/inventory-consumptions', + ); + expect(consumptions.request.params.get('status')).toBe('received_pending'); + expect(consumptions.request.params.get('interventionId')).toBe('intervention'); + consumptions.flush({ member: [], totalItems: 0 }); + }); + + it('preserves the complete exact declaration and immutable UUID across response-loss retry', () => { + let failure: unknown; + service.declareConsumption('org', declaration).subscribe({ + error: (error: unknown) => { + failure = error; + }, + }); + const lost = http.expectOne(base + '/inventory-consumptions'); + expect(lost.request.method).toBe('POST'); + expect(lost.request.body).toEqual(declaration); + expect(lost.request.body.quantity).toBe('999999999999999999.123456'); + lost.error(new ProgressEvent('error')); + expect(failure).toMatchObject({ status: 0 }); + let received: InventoryConsumptionOutput | undefined; + service.declareConsumption('org', declaration).subscribe((value) => { + received = value; + }); + const retry = http.expectOne(base + '/inventory-consumptions'); + expect(retry.request.body).toEqual(lost.request.body); + expect(retry.request.body.clientOperationId).toBe(declaration.clientOperationId); + retry.flush({ ...receipt, replayed: true }); + expect(received?.id).toBe('server-id'); + expect(received?.status).toBe('received_pending'); + expect(received?.replayed).toBe(true); + }); + + it('reconciles a declaration with an empty POST body without rewriting its physical facts', () => { + service.reconcileConsumption('org', 'receipt/id').subscribe(); + const request = http.expectOne(base + '/inventory-consumptions/receipt%2Fid/reconcile'); + expect(request.request.method).toBe('POST'); + expect(request.request.body).toEqual({}); + request.flush(receipt); + }); + + it('sends motivated returns and signed corrections as exact-string compensating commands', () => { + const returned = { + clientOperationId: 'return-id', + consumptionId: 'server-id', + quantity: '0.000001', + reason: 'Unused seal', + }; + service.returnConsumption('org', returned).subscribe(); + const returnRequest = http.expectOne(base + '/inventory-returns'); + expect(returnRequest.request.method).toBe('POST'); + expect(returnRequest.request.body).toEqual(returned); + returnRequest.flush({ '@id': '/movement', '@type': 'InventoryMovement' }); + const corrected = { + clientOperationId: 'correction-id', + partId: 'part', + warehouseId: 'warehouse', + quantity: '-999999999999999999.000001', + reason: 'Count correction', + unitCost: '99999999999999.123456', + }; + service.correctStock('org', corrected).subscribe(); + const correction = http.expectOne(base + '/inventory-corrections'); + expect(correction.request.body).toEqual(corrected); + expect(correction.request.body.unitCost).toBe('99999999999999.123456'); + correction.flush({ '@id': '/movement', '@type': 'InventoryMovement' }); + }); + + it('creates and updates references using merge patches without an invented revision precondition', () => { + const part = { code: 'SEAL', label: 'Seal', unit: 'piece', kind: 'part' as const }; + service.createPart('org', part).subscribe(); + const createPart = http.expectOne(base + '/inventory-parts'); + expect(createPart.request.body).toEqual(part); + createPart.flush({ '@id': '/part', '@type': 'InventoryPart' }); + service.createWarehouse('org', { code: 'VAN', name: 'Van' }).subscribe(); + const createWarehouse = http.expectOne(base + '/inventory-warehouses'); + expect(createWarehouse.request.method).toBe('POST'); + createWarehouse.flush({ '@id': '/warehouse', '@type': 'InventoryWarehouse' }); + service.updatePart('org', 'part/id', { label: 'Archived seal', archived: true }).subscribe(); + const updatePart = http.expectOne(base + '/inventory-parts/part%2Fid'); + expect(updatePart.request.method).toBe('PATCH'); + expect(updatePart.request.headers.get('Content-Type')).toBe('application/merge-patch+json'); + expect(updatePart.request.headers.has('If-Match')).toBe(false); + expect(updatePart.request.body).toEqual({ label: 'Archived seal', archived: true }); + updatePart.flush({ '@id': '/part', '@type': 'InventoryPart' }); + service + .updateWarehouse('org', 'warehouse/id', { name: 'Restored van', archived: false }) + .subscribe(); + const updateWarehouse = http.expectOne(base + '/inventory-warehouses/warehouse%2Fid'); + expect(updateWarehouse.request.method).toBe('PATCH'); + expect(updateWarehouse.request.headers.has('If-Match')).toBe(false); + expect(updateWarehouse.request.body).toEqual({ name: 'Restored van', archived: false }); + updateWarehouse.flush({ '@id': '/warehouse', '@type': 'InventoryWarehouse' }); + }); + + it('propagates organization permission and immutable identity conflicts without changing the command', () => { + let failure: unknown; + service.declareConsumption('org', declaration).subscribe({ + error: (error: unknown) => { + failure = error; + }, + }); + const conflict = http.expectOne(base + '/inventory-consumptions'); + conflict.flush( + { + status: 409, + type: 'identity-conflict', + detail: 'Operation UUID belongs to another intention.', + }, + { status: 409, statusText: 'Conflict' }, + ); + expect(failure).toMatchObject({ + status: 409, + detail: 'Operation UUID belongs to another intention.', + }); + expect(declaration.quantity).toBe('999999999999999999.123456'); + expect(declaration.clientOperationId).toBe('77306066-a66e-44e4-909b-100ba3ec79d4'); + service.listBalances('other-org').subscribe({ + error: (error: unknown) => { + failure = error; + }, + }); + const forbidden = http.expectOne( + 'https://api.test/api/organizations/other-org/inventory-balances', + ); + forbidden.flush( + { status: 403, type: 'forbidden', detail: 'Inventory read denied.' }, + { status: 403, statusText: 'Forbidden' }, + ); + expect(failure).toMatchObject({ status: 403, detail: 'Inventory read denied.' }); + }); +}); diff --git a/src/app/features/organization/features/inventory/inventory.routes.ts b/src/app/features/organization/features/inventory/inventory.routes.ts new file mode 100644 index 000000000..91f51b4b3 --- /dev/null +++ b/src/app/features/organization/features/inventory/inventory.routes.ts @@ -0,0 +1,43 @@ +import type { Routes } from '@angular/router'; +import { organizationPermissionGuard } from '@features/organization/http/guards'; +import { ORGANIZATION_PERMISSION } from '@features/organization/models'; +import { unsavedChangesGuard } from '@shared/unsaved-changes'; +/** + * Constant INVENTORY_ROUTES + * + * @description + * All stock sections share organization read access; actions require their dedicated permission. + */ +export const INVENTORY_ROUTES: Routes = [ + { + path: '', + canActivate: [ + organizationPermissionGuard({ permissions: [ORGANIZATION_PERMISSION.INVENTORY_READ] }), + ], + data: { breadcrumb: $localize`:@@inventory.title:Inventory` }, + children: [ + { path: '', pathMatch: 'full', redirectTo: 'balances' }, + ...(['balances', 'parts', 'warehouses', 'consumptions', 'movements'] as const).map( + (section) => ({ + path: section, + canDeactivate: [unsavedChangesGuard], + loadComponent: () => + import('./ui/pages/inventory-page/inventory-page.component').then( + (module) => module.InventoryPage, + ), + title: + section === 'balances' + ? $localize`:@@inventory.section.balances:Stock` + : section === 'parts' + ? $localize`:@@inventory.section.parts:Parts and consumables` + : section === 'warehouses' + ? $localize`:@@inventory.section.warehouses:Warehouses` + : section === 'consumptions' + ? $localize`:@@inventory.section.consumptions:Consumption declarations` + : $localize`:@@inventory.section.movements:Movement history`, + data: { section, breadcrumb: false }, + }), + ), + ], + }, +]; diff --git a/src/app/features/organization/features/inventory/models/index.ts b/src/app/features/organization/features/inventory/models/index.ts new file mode 100644 index 000000000..083b43db7 --- /dev/null +++ b/src/app/features/organization/features/inventory/models/index.ts @@ -0,0 +1,20 @@ +export type { InventoryPartOutput } from './inventory-part/inventory-part-output.interface'; +export type { CreateInventoryPartInput } from './inventory-part/create-inventory-part-input.interface'; +export type { UpdateInventoryPartInput } from './inventory-part/update-inventory-part-input.interface'; +export type { InventoryWarehouseOutput } from './inventory-warehouse/inventory-warehouse-output.interface'; +export type { CreateInventoryWarehouseInput } from './inventory-warehouse/create-inventory-warehouse-input.interface'; +export type { UpdateInventoryWarehouseInput } from './inventory-warehouse/update-inventory-warehouse-input.interface'; +export type { InventoryBalanceOutput } from './inventory-stock/inventory-balance-output.interface'; +export type { InventoryMovementOutput } from './inventory-stock/inventory-movement-output.interface'; +export type { InventoryConsumptionOutput } from './inventory-consumption/inventory-consumption-output.interface'; +export type { DeclareInventoryConsumptionInput } from './inventory-consumption/declare-inventory-consumption-input.interface'; +export type { InventoryConsumptionIntent } from './inventory-consumption/inventory-consumption-intent.interface'; +export type { ReturnInventoryConsumptionInput } from './inventory-consumption/return-inventory-consumption-input.interface'; +export type { CorrectInventoryStockInput } from './inventory-stock/correct-inventory-stock-input.interface'; +export type { InventoryValuationOutput } from './inventory-stock/inventory-valuation-output.interface'; +export type { + InventoryConsumptionStatus, + InventoryConsumptionReason, +} from './inventory-consumption/inventory-consumption-status.type'; +export type { InventoryMovementKind } from './inventory-stock/inventory-movement-kind.type'; +export type { InventoryPhysicalCommand } from './inventory-stock/inventory-physical-command.type'; diff --git a/src/app/features/organization/features/inventory/models/inventory-consumption/declare-inventory-consumption-input.interface.ts b/src/app/features/organization/features/inventory/models/inventory-consumption/declare-inventory-consumption-input.interface.ts new file mode 100644 index 000000000..adf9aa65b --- /dev/null +++ b/src/app/features/organization/features/inventory/models/inventory-consumption/declare-inventory-consumption-input.interface.ts @@ -0,0 +1,122 @@ +/** + * Interface DeclareInventoryConsumptionInput + * @interface DeclareInventoryConsumptionInput + * + * @description + * Immutable physical consumption intent keeps the same UUID and decimal text through retries. + * + * @since unreleased + */ +export interface DeclareInventoryConsumptionInput { + /** + * Property clientOperationId + * @readonly + * + * @description + * Stable UUID generated once for this physical declaration. + * + * @access public + * @since unreleased + * + * @type {string} + */ + readonly clientOperationId: string; + + /** + * Property partId + * @readonly + * + * @description + * Part or consumable reference identity. + * + * @access public + * @since unreleased + * + * @type {string} + */ + readonly partId: string; + + /** + * Property warehouseId + * @readonly + * + * @description + * Physical source warehouse identity. + * + * @access public + * @since unreleased + * + * @type {string} + */ + readonly warehouseId: string; + + /** + * Property quantity + * @readonly + * + * @description + * Exact positive decimal quantity, with at most six fractional digits. + * + * @access public + * @since unreleased + * + * @type {string} + */ + readonly quantity: string; + + /** + * Property interventionId + * @readonly + * + * @description + * Intervention owning the declared physical usage. + * + * @access public + * @since unreleased + * + * @type {string} + */ + readonly interventionId: string; + + /** + * Property workItemId + * @readonly + * + * @description + * Optional execution work item. + * + * @access public + * @since unreleased + * + * @type {string | null | undefined} + */ + readonly workItemId?: string | null; + + /** + * Property equipmentId + * @readonly + * + * @description + * Optional equipment affected by the work. + * + * @access public + * @since unreleased + * + * @type {string | null | undefined} + */ + readonly equipmentId?: string | null; + + /** + * Property occurredAt + * @readonly + * + * @description + * ISO timestamp of the real usage, preserved during replay. + * + * @access public + * @since unreleased + * + * @type {string} + */ + readonly occurredAt: string; +} diff --git a/src/app/features/organization/features/inventory/models/inventory-consumption/inventory-consumption-intent.interface.ts b/src/app/features/organization/features/inventory/models/inventory-consumption/inventory-consumption-intent.interface.ts new file mode 100644 index 000000000..98166703f --- /dev/null +++ b/src/app/features/organization/features/inventory/models/inventory-consumption/inventory-consumption-intent.interface.ts @@ -0,0 +1,55 @@ +import type { DeclareInventoryConsumptionInput } from './declare-inventory-consumption-input.interface'; + +/** + * Interface InventoryConsumptionIntent + * @interface InventoryConsumptionIntent + * + * @description + * A parent-owned durable outbox projection distinguishes local queue state from server stock + * confirmation. + * + * @since unreleased + */ +export interface InventoryConsumptionIntent { + /** + * Property input + * @readonly + * + * @description + * Immutable consumption payload whose UUID stays stable. + * + * @access public + * @since unreleased + * + * @type {DeclareInventoryConsumptionInput} + */ + readonly input: DeclareInventoryConsumptionInput; + + /** + * Property status + * @readonly + * + * @description + * Local persistence and transmission state; never implies stock confirmation. + * + * @access public + * @since unreleased + * + * @type {'queued' | 'sending' | 'failed'} + */ + readonly status: 'queued' | 'sending' | 'failed'; + + /** + * Property error + * @readonly + * + * @description + * Recoverable persistence or transmission feedback. + * + * @access public + * @since unreleased + * + * @type {string | null | undefined} + */ + readonly error?: string | null; +} diff --git a/src/app/features/organization/features/inventory/models/inventory-consumption/inventory-consumption-output.interface.ts b/src/app/features/organization/features/inventory/models/inventory-consumption/inventory-consumption-output.interface.ts new file mode 100644 index 000000000..8ef82e8e9 --- /dev/null +++ b/src/app/features/organization/features/inventory/models/inventory-consumption/inventory-consumption-output.interface.ts @@ -0,0 +1,212 @@ +import type { HydraItem } from '@core/api/models'; +import type { + InventoryConsumptionStatus, + InventoryConsumptionReason, +} from './inventory-consumption-status.type'; + +/** + * Interface InventoryConsumptionOutput + * @interface InventoryConsumptionOutput + * + * @description + * A received physical declaration is independent of work completion and dossier publication. + * + * @since unreleased + */ +export interface InventoryConsumptionOutput extends HydraItem { + /** + * Property id + * @readonly + * + * @description + * Stable server declaration identity, distinct from the client operation UUID. + * + * @access public + * @since unreleased + * + * @type {string} + */ + readonly id: string; + + /** + * Property partId + * @readonly + * + * @description + * Quantitative reference physically consumed. + * + * @access public + * @since unreleased + * + * @type {string} + */ + readonly partId: string; + + /** + * Property warehouseId + * @readonly + * + * @description + * Declared source warehouse. + * + * @access public + * @since unreleased + * + * @type {string} + */ + readonly warehouseId: string; + + /** + * Property quantity + * @readonly + * + * @description + * Exact positive consumed quantity with six fractional digits. + * + * @access public + * @since unreleased + * + * @type {string} + */ + readonly quantity: string; + + /** + * Property interventionId + * @readonly + * + * @description + * Intervention owning this resource declaration. + * + * @access public + * @since unreleased + * + * @type {string} + */ + readonly interventionId: string; + + /** + * Property workItemId + * @readonly + * + * @description + * Optional execution work item. + * + * @access public + * @since unreleased + * + * @type {string | null | undefined} + */ + readonly workItemId?: string | null; + + /** + * Property equipmentId + * @readonly + * + * @description + * Optional equipment affected by the work. + * + * @access public + * @since unreleased + * + * @type {string | null | undefined} + */ + readonly equipmentId?: string | null; + + /** + * Property actorId + * @readonly + * + * @description + * Identity of the recording actor. + * + * @access public + * @since unreleased + * + * @type {string} + */ + readonly actorId: string; + + /** + * Property occurredAt + * @readonly + * + * @description + * Recorded ISO timestamp of the physical usage. + * + * @access public + * @since unreleased + * + * @type {string} + */ + readonly occurredAt: string; + + /** + * Property status + * @readonly + * + * @description + * Confirmation or reconciliation state of the full stock debit. + * + * @access public + * @since unreleased + * + * @type {InventoryConsumptionStatus} + */ + readonly status: InventoryConsumptionStatus; + + /** + * Property reason + * @readonly + * + * @description + * Optional reason preventing atomic confirmation. + * + * @access public + * @since unreleased + * + * @type {InventoryConsumptionReason | null | undefined} + */ + readonly reason?: InventoryConsumptionReason | null; + + /** + * Property movementId + * @readonly + * + * @description + * Confirmed stock movement identity when available. + * + * @access public + * @since unreleased + * + * @type {string | null | undefined} + */ + readonly movementId?: string | null; + + /** + * Property late + * @readonly + * + * @description + * Marks a declaration received after dossier publication. + * + * @access public + * @since unreleased + * + * @type {boolean} + */ + readonly late: boolean; + + /** + * Property replayed + * @readonly + * + * @description + * The command returned its original server receipt; GET exposes current state. + * + * @access public + * @since unreleased + * + * @type {boolean} + */ + readonly replayed: boolean; +} diff --git a/src/app/features/organization/features/inventory/models/inventory-consumption/inventory-consumption-status.type.ts b/src/app/features/organization/features/inventory/models/inventory-consumption/inventory-consumption-status.type.ts new file mode 100644 index 000000000..c7e63be65 --- /dev/null +++ b/src/app/features/organization/features/inventory/models/inventory-consumption/inventory-consumption-status.type.ts @@ -0,0 +1,22 @@ +/** + * Type InventoryConsumptionStatus + * + * @description + * Canonical server stock-confirmation states, separate from local queuing and work completion. + * + * @type InventoryConsumptionStatus + */ +export type InventoryConsumptionStatus = 'confirmed' | 'received_pending'; + +/** + * Type InventoryConsumptionReason + * + * @description + * Canonical reconciliation causes returned by the server. + * + * @type InventoryConsumptionReason + */ +export type InventoryConsumptionReason = + | 'missing_balance' + | 'archived_reference' + | 'insufficient_stock'; diff --git a/src/app/features/organization/features/inventory/models/inventory-consumption/return-inventory-consumption-input.interface.ts b/src/app/features/organization/features/inventory/models/inventory-consumption/return-inventory-consumption-input.interface.ts new file mode 100644 index 000000000..2ae305073 --- /dev/null +++ b/src/app/features/organization/features/inventory/models/inventory-consumption/return-inventory-consumption-input.interface.ts @@ -0,0 +1,66 @@ +/** + * Interface ReturnInventoryConsumptionInput + * @interface ReturnInventoryConsumptionInput + * + * @description + * An explicit physical return creates a compensating movement without rewriting the consumption. + * + * @since unreleased + */ +export interface ReturnInventoryConsumptionInput { + /** + * Property clientOperationId + * @readonly + * + * @description + * Stable UUID preserved through uncertain network responses. + * + * @access public + * @since unreleased + * + * @type {string} + */ + readonly clientOperationId: string; + + /** + * Property consumptionId + * @readonly + * + * @description + * Original confirmed consumption identity. + * + * @access public + * @since unreleased + * + * @type {string} + */ + readonly consumptionId: string; + + /** + * Property quantity + * @readonly + * + * @description + * Exact positive quantity returned to stock. + * + * @access public + * @since unreleased + * + * @type {string} + */ + readonly quantity: string; + + /** + * Property reason + * @readonly + * + * @description + * Recorded factual return reason, limited to 2000 characters. + * + * @access public + * @since unreleased + * + * @type {string} + */ + readonly reason: string; +} diff --git a/src/app/features/organization/features/inventory/models/inventory-part/create-inventory-part-input.interface.ts b/src/app/features/organization/features/inventory/models/inventory-part/create-inventory-part-input.interface.ts new file mode 100644 index 000000000..3bc9985ea --- /dev/null +++ b/src/app/features/organization/features/inventory/models/inventory-part/create-inventory-part-input.interface.ts @@ -0,0 +1,66 @@ +/** + * Interface CreateInventoryPartInput + * @interface CreateInventoryPartInput + * + * @description + * Creates a quantitative reference, without registering an individual equipment. + * + * @since unreleased + */ +export interface CreateInventoryPartInput { + /** + * Property code + * @readonly + * + * @description + * Permanent unique organization code, limited to 100 characters. + * + * @access public + * @since unreleased + * + * @type {string} + */ + readonly code: string; + + /** + * Property label + * @readonly + * + * @description + * Display name, limited to 255 characters. + * + * @access public + * @since unreleased + * + * @type {string} + */ + readonly label: string; + + /** + * Property unit + * @readonly + * + * @description + * Declared stock unit, limited to 32 characters. + * + * @access public + * @since unreleased + * + * @type {string} + */ + readonly unit: string; + + /** + * Property kind + * @readonly + * + * @description + * Immutable quantitative reference category. + * + * @access public + * @since unreleased + * + * @type {'part' | 'consumable'} + */ + readonly kind: 'part' | 'consumable'; +} diff --git a/src/app/features/organization/features/inventory/models/inventory-part/inventory-part-output.interface.ts b/src/app/features/organization/features/inventory/models/inventory-part/inventory-part-output.interface.ts new file mode 100644 index 000000000..25674c7ee --- /dev/null +++ b/src/app/features/organization/features/inventory/models/inventory-part/inventory-part-output.interface.ts @@ -0,0 +1,97 @@ +import type { HydraItem } from '@core/api/models'; + +/** + * Interface InventoryPartOutput + * @interface InventoryPartOutput + * + * @description + * An organization-owned quantitative part or consumable; complete equipment stays in the equipment + * park. + * + * @since unreleased + */ +export interface InventoryPartOutput extends HydraItem { + /** + * Property id + * @readonly + * + * @description + * Stable reference identity. + * + * @access public + * @since unreleased + * + * @type {string} + */ + readonly id: string; + + /** + * Property code + * @readonly + * + * @description + * Permanent organization reference code. + * + * @access public + * @since unreleased + * + * @type {string} + */ + readonly code: string; + + /** + * Property label + * @readonly + * + * @description + * Display name of the part or consumable. + * + * @access public + * @since unreleased + * + * @type {string} + */ + readonly label: string; + + /** + * Property unit + * @readonly + * + * @description + * Declared unit used for exact stock quantities. + * + * @access public + * @since unreleased + * + * @type {string} + */ + readonly unit: string; + + /** + * Property kind + * @readonly + * + * @description + * Permanent quantitative reference category. + * + * @access public + * @since unreleased + * + * @type {'part' | 'consumable'} + */ + readonly kind: 'part' | 'consumable'; + + /** + * Property archived + * @readonly + * + * @description + * Archived references remain readable in histories. + * + * @access public + * @since unreleased + * + * @type {boolean} + */ + readonly archived: boolean; +} diff --git a/src/app/features/organization/features/inventory/models/inventory-part/update-inventory-part-input.interface.ts b/src/app/features/organization/features/inventory/models/inventory-part/update-inventory-part-input.interface.ts new file mode 100644 index 000000000..543640e1b --- /dev/null +++ b/src/app/features/organization/features/inventory/models/inventory-part/update-inventory-part-input.interface.ts @@ -0,0 +1,53 @@ +/** + * Interface UpdateInventoryPartInput + * @interface UpdateInventoryPartInput + * + * @description + * Only mutable reference descriptions and archive state can be changed; code and kind stay + * permanent. + * + * @since unreleased + */ +export interface UpdateInventoryPartInput { + /** + * Property label + * @readonly + * + * @description + * Optional replacement display name. + * + * @access public + * @since unreleased + * + * @type {string | undefined} + */ + readonly label?: string; + + /** + * Property unit + * @readonly + * + * @description + * Optional replacement declared stock unit. + * + * @access public + * @since unreleased + * + * @type {string | undefined} + */ + readonly unit?: string; + + /** + * Property archived + * @readonly + * + * @description + * Optional archive or restoration intent. + * + * @access public + * @since unreleased + * + * @type {boolean | undefined} + */ + readonly archived?: boolean; +} diff --git a/src/app/features/organization/features/inventory/models/inventory-stock/correct-inventory-stock-input.interface.ts b/src/app/features/organization/features/inventory/models/inventory-stock/correct-inventory-stock-input.interface.ts new file mode 100644 index 000000000..42de860f2 --- /dev/null +++ b/src/app/features/organization/features/inventory/models/inventory-stock/correct-inventory-stock-input.interface.ts @@ -0,0 +1,95 @@ +/** + * Interface CorrectInventoryStockInput + * @interface CorrectInventoryStockInput + * + * @description + * A motivated stock adjustment creates a new signed movement and requires financial management + * permission. + * + * @since unreleased + */ +export interface CorrectInventoryStockInput { + /** + * Property clientOperationId + * @readonly + * + * @description + * Stable UUID preserved through command retries. + * + * @access public + * @since unreleased + * + * @type {string} + */ + readonly clientOperationId: string; + + /** + * Property partId + * @readonly + * + * @description + * Reference whose balance is being adjusted. + * + * @access public + * @since unreleased + * + * @type {string} + */ + readonly partId: string; + + /** + * Property warehouseId + * @readonly + * + * @description + * Warehouse whose balance is being adjusted. + * + * @access public + * @since unreleased + * + * @type {string} + */ + readonly warehouseId: string; + + /** + * Property quantity + * @readonly + * + * @description + * Nonzero exact signed delta, with at most six fractional digits. + * + * @access public + * @since unreleased + * + * @type {string} + */ + readonly quantity: string; + + /** + * Property reason + * @readonly + * + * @description + * Recorded factual adjustment reason, limited to 2000 characters. + * + * @access public + * @since unreleased + * + * @type {string} + */ + readonly reason: string; + + /** + * Property unitCost + * @readonly + * + * @description + * Optional exact internal unit cost; omitted when unknown. + * + * @access public + * @since unreleased + * + * @type {string | null | undefined} + */ + readonly unitCost?: string | null; +} diff --git a/src/app/features/organization/features/inventory/models/inventory-stock/inventory-balance-output.interface.ts b/src/app/features/organization/features/inventory/models/inventory-stock/inventory-balance-output.interface.ts new file mode 100644 index 000000000..e6667bd96 --- /dev/null +++ b/src/app/features/organization/features/inventory/models/inventory-stock/inventory-balance-output.interface.ts @@ -0,0 +1,68 @@ +import type { HydraItem } from '@core/api/models'; + +/** + * Interface InventoryBalanceOutput + * @interface InventoryBalanceOutput + * + * @description + * Ordinary inventory balance projection contains quantities only, without financial fields. + * + * @since unreleased + */ +export interface InventoryBalanceOutput extends HydraItem { + /** + * Property id + * @readonly + * + * @description + * Stable stock balance identity. + * + * @access public + * @since unreleased + * + * @type {string} + */ + readonly id: string; + + /** + * Property partId + * @readonly + * + * @description + * Quantitative reference whose stock is tracked. + * + * @access public + * @since unreleased + * + * @type {string} + */ + readonly partId: string; + + /** + * Property warehouseId + * @readonly + * + * @description + * Warehouse holding this balance. + * + * @access public + * @since unreleased + * + * @type {string} + */ + readonly warehouseId: string; + + /** + * Property quantity + * @readonly + * + * @description + * Exact nonnegative decimal quantity, serialized with six fractional digits. + * + * @access public + * @since unreleased + * + * @type {string} + */ + readonly quantity: string; +} diff --git a/src/app/features/organization/features/inventory/models/inventory-stock/inventory-movement-kind.type.ts b/src/app/features/organization/features/inventory/models/inventory-stock/inventory-movement-kind.type.ts new file mode 100644 index 000000000..7ac2cefba --- /dev/null +++ b/src/app/features/organization/features/inventory/models/inventory-stock/inventory-movement-kind.type.ts @@ -0,0 +1,14 @@ +/** + * Type InventoryMovementKind + * + * @description + * Canonical immutable stock movement categories. + * + * @type InventoryMovementKind + */ +export type InventoryMovementKind = + | 'receipt' + | 'receipt_return' + | 'consumption' + | 'return' + | 'correction'; diff --git a/src/app/features/organization/features/inventory/models/inventory-stock/inventory-movement-output.interface.ts b/src/app/features/organization/features/inventory/models/inventory-stock/inventory-movement-output.interface.ts new file mode 100644 index 000000000..6b6fb8d30 --- /dev/null +++ b/src/app/features/organization/features/inventory/models/inventory-stock/inventory-movement-output.interface.ts @@ -0,0 +1,223 @@ +import type { HydraItem } from '@core/api/models'; +import type { InventoryMovementKind } from './inventory-movement-kind.type'; + +/** + * Interface InventoryMovementOutput + * @interface InventoryMovementOutput + * + * @description + * Immutable ordinary movement projection tracks exact physical deltas without internal prices. + * + * @since unreleased + */ +export interface InventoryMovementOutput extends HydraItem { + /** + * Property id + * @readonly + * + * @description + * Stable movement identity. + * + * @access public + * @since unreleased + * + * @type {string} + */ + readonly id: string; + + /** + * Property partId + * @readonly + * + * @description + * Reference affected by this movement. + * + * @access public + * @since unreleased + * + * @type {string} + */ + readonly partId: string; + + /** + * Property warehouseId + * @readonly + * + * @description + * Warehouse affected by this movement. + * + * @access public + * @since unreleased + * + * @type {string} + */ + readonly warehouseId: string; + + /** + * Property kind + * @readonly + * + * @description + * Canonical movement category. + * + * @access public + * @since unreleased + * + * @type {InventoryMovementKind} + */ + readonly kind: InventoryMovementKind; + + /** + * Property quantity + * @readonly + * + * @description + * Signed exact stock delta, serialized with six fractional digits. + * + * @access public + * @since unreleased + * + * @type {string} + */ + readonly quantity: string; + + /** + * Property reason + * @readonly + * + * @description + * Recorded factual reason for the movement. + * + * @access public + * @since unreleased + * + * @type {string} + */ + readonly reason: string; + + /** + * Property actorId + * @readonly + * + * @description + * Identity of the recording actor. + * + * @access public + * @since unreleased + * + * @type {string} + */ + readonly actorId: string; + + /** + * Property occurredAt + * @readonly + * + * @description + * Recorded ISO timestamp of the physical operation. + * + * @access public + * @since unreleased + * + * @type {string} + */ + readonly occurredAt: string; + + /** + * Property interventionId + * @readonly + * + * @description + * Optional intervention owning the resource usage. + * + * @access public + * @since unreleased + * + * @type {string | null | undefined} + */ + readonly interventionId?: string | null; + + /** + * Property workItemId + * @readonly + * + * @description + * Optional execution work item. + * + * @access public + * @since unreleased + * + * @type {string | null | undefined} + */ + readonly workItemId?: string | null; + + /** + * Property equipmentId + * @readonly + * + * @description + * Optional equipment affected by the work. + * + * @access public + * @since unreleased + * + * @type {string | null | undefined} + */ + readonly equipmentId?: string | null; + + /** + * Property correctionOf + * @readonly + * + * @description + * Original movement identity corrected or returned. + * + * @access public + * @since unreleased + * + * @type {string | null | undefined} + */ + readonly correctionOf?: string | null; + + /** + * Property sourceReceiptId + * @readonly + * + * @description + * Optional procurement receipt identity. + * + * @access public + * @since unreleased + * + * @type {string | null | undefined} + */ + readonly sourceReceiptId?: string | null; + + /** + * Property late + * @readonly + * + * @description + * Marks a factual declaration recorded after dossier publication. + * + * @access public + * @since unreleased + * + * @type {boolean} + */ + readonly late: boolean; + + /** + * Property replayed + * @readonly + * + * @description + * The command returned its existing server receipt. + * + * @access public + * @since unreleased + * + * @type {boolean} + */ + readonly replayed: boolean; +} diff --git a/src/app/features/organization/features/inventory/models/inventory-stock/inventory-physical-command.type.ts b/src/app/features/organization/features/inventory/models/inventory-stock/inventory-physical-command.type.ts new file mode 100644 index 000000000..b9af989f6 --- /dev/null +++ b/src/app/features/organization/features/inventory/models/inventory-stock/inventory-physical-command.type.ts @@ -0,0 +1,24 @@ +import type { ReturnInventoryConsumptionInput } from '../inventory-consumption/return-inventory-consumption-input.interface'; +import type { CorrectInventoryStockInput } from './correct-inventory-stock-input.interface'; + +/** + * Type InventoryPhysicalCommand + * + * @description + * Durable immutable intention for a return or a signed stock correction. + * + * @type InventoryPhysicalCommand + */ +export type InventoryPhysicalCommand = + | { + readonly kind: 'correction'; + readonly organizationId: string; + readonly userId: string; + readonly input: CorrectInventoryStockInput; + } + | { + readonly kind: 'return'; + readonly organizationId: string; + readonly userId: string; + readonly input: ReturnInventoryConsumptionInput; + }; diff --git a/src/app/features/organization/features/inventory/models/inventory-stock/inventory-valuation-output.interface.ts b/src/app/features/organization/features/inventory/models/inventory-stock/inventory-valuation-output.interface.ts new file mode 100644 index 000000000..12edb583e --- /dev/null +++ b/src/app/features/organization/features/inventory/models/inventory-stock/inventory-valuation-output.interface.ts @@ -0,0 +1,66 @@ +/** + * Interface InventoryValuationOutput + * @interface InventoryValuationOutput + * + * @description + * Dedicated financial stock projection is never part of the ordinary quantity-only contract. + * + * @since unreleased + */ +export interface InventoryValuationOutput { + /** + * Property unitCost + * @readonly + * + * @description + * Exact internal average or movement unit cost when known. + * + * @access public + * @since unreleased + * + * @type {string | null | undefined} + */ + readonly unitCost?: string | null; + + /** + * Property totalValue + * @readonly + * + * @description + * Exact internal value or signed value delta when known. + * + * @access public + * @since unreleased + * + * @type {string | null | undefined} + */ + readonly totalValue?: string | null; + + /** + * Property currency + * @readonly + * + * @description + * Organization valuation currency. + * + * @access public + * @since unreleased + * + * @type {string} + */ + readonly currency: string; + + /** + * Property incomplete + * @readonly + * + * @description + * Unknown valuation remains explicit rather than being treated as zero. + * + * @access public + * @since unreleased + * + * @type {boolean} + */ + readonly incomplete: boolean; +} diff --git a/src/app/features/organization/features/inventory/models/inventory-warehouse/create-inventory-warehouse-input.interface.ts b/src/app/features/organization/features/inventory/models/inventory-warehouse/create-inventory-warehouse-input.interface.ts new file mode 100644 index 000000000..16c8fd5c2 --- /dev/null +++ b/src/app/features/organization/features/inventory/models/inventory-warehouse/create-inventory-warehouse-input.interface.ts @@ -0,0 +1,38 @@ +/** + * Interface CreateInventoryWarehouseInput + * @interface CreateInventoryWarehouseInput + * + * @description + * Creates a warehouse with a permanent code. + * + * @since unreleased + */ +export interface CreateInventoryWarehouseInput { + /** + * Property code + * @readonly + * + * @description + * Permanent unique organization code, limited to 100 characters. + * + * @access public + * @since unreleased + * + * @type {string} + */ + readonly code: string; + + /** + * Property name + * @readonly + * + * @description + * Warehouse display name, limited to 255 characters. + * + * @access public + * @since unreleased + * + * @type {string} + */ + readonly name: string; +} diff --git a/src/app/features/organization/features/inventory/models/inventory-warehouse/inventory-warehouse-output.interface.ts b/src/app/features/organization/features/inventory/models/inventory-warehouse/inventory-warehouse-output.interface.ts new file mode 100644 index 000000000..7e6b3b94f --- /dev/null +++ b/src/app/features/organization/features/inventory/models/inventory-warehouse/inventory-warehouse-output.interface.ts @@ -0,0 +1,68 @@ +import type { HydraItem } from '@core/api/models'; + +/** + * Interface InventoryWarehouseOutput + * @interface InventoryWarehouseOutput + * + * @description + * An organization-owned stock warehouse, distinct from equipment placement. + * + * @since unreleased + */ +export interface InventoryWarehouseOutput extends HydraItem { + /** + * Property id + * @readonly + * + * @description + * Stable warehouse identity. + * + * @access public + * @since unreleased + * + * @type {string} + */ + readonly id: string; + + /** + * Property code + * @readonly + * + * @description + * Permanent unique organization code. + * + * @access public + * @since unreleased + * + * @type {string} + */ + readonly code: string; + + /** + * Property name + * @readonly + * + * @description + * Warehouse display name. + * + * @access public + * @since unreleased + * + * @type {string} + */ + readonly name: string; + + /** + * Property archived + * @readonly + * + * @description + * Archived warehouses remain readable in movement histories. + * + * @access public + * @since unreleased + * + * @type {boolean} + */ + readonly archived: boolean; +} diff --git a/src/app/features/organization/features/inventory/models/inventory-warehouse/update-inventory-warehouse-input.interface.ts b/src/app/features/organization/features/inventory/models/inventory-warehouse/update-inventory-warehouse-input.interface.ts new file mode 100644 index 000000000..ff5e4f8bc --- /dev/null +++ b/src/app/features/organization/features/inventory/models/inventory-warehouse/update-inventory-warehouse-input.interface.ts @@ -0,0 +1,38 @@ +/** + * Interface UpdateInventoryWarehouseInput + * @interface UpdateInventoryWarehouseInput + * + * @description + * Only the display name and archive state of an existing warehouse can change. + * + * @since unreleased + */ +export interface UpdateInventoryWarehouseInput { + /** + * Property name + * @readonly + * + * @description + * Optional replacement display name. + * + * @access public + * @since unreleased + * + * @type {string | undefined} + */ + readonly name?: string; + + /** + * Property archived + * @readonly + * + * @description + * Optional archive or restoration intent. + * + * @access public + * @since unreleased + * + * @type {boolean | undefined} + */ + readonly archived?: boolean; +} diff --git a/src/app/features/organization/features/inventory/state/index.ts b/src/app/features/organization/features/inventory/state/index.ts new file mode 100644 index 000000000..d74cab2f5 --- /dev/null +++ b/src/app/features/organization/features/inventory/state/index.ts @@ -0,0 +1,9 @@ +export { InventoryPickerStore, type InventoryPickerStoreType } from './inventory-picker'; +export { + InventoryStore, + type InventoryStoreType, + type InventoryQuery, + type InventorySection, + type InventoryCommand, + inventoryEvents, +} from './inventory'; diff --git a/src/app/features/organization/features/inventory/state/inventory-picker/index.ts b/src/app/features/organization/features/inventory/state/inventory-picker/index.ts new file mode 100644 index 000000000..2c609d4aa --- /dev/null +++ b/src/app/features/organization/features/inventory/state/inventory-picker/index.ts @@ -0,0 +1 @@ +export { InventoryPickerStore, type InventoryPickerStoreType } from './inventory-picker.store'; diff --git a/src/app/features/organization/features/inventory/state/inventory-picker/inventory-picker.store.ts b/src/app/features/organization/features/inventory/state/inventory-picker/inventory-picker.store.ts new file mode 100644 index 000000000..3b67ddc07 --- /dev/null +++ b/src/app/features/organization/features/inventory/state/inventory-picker/inventory-picker.store.ts @@ -0,0 +1,276 @@ +import { computed, inject } from '@angular/core'; +import { tapResponse } from '@ngrx/operators'; +import { patchState, signalStore, type, withComputed, withMethods, withState } from '@ngrx/signals'; +import { removeAllEntities, setAllEntities, withEntities } from '@ngrx/signals/entities'; +import { rxMethod } from '@ngrx/signals/rxjs-interop'; +import { EMPTY, Subject, pipe, switchMap, takeUntil, type Observable } from 'rxjs'; +import type { HydraCollection } from '@core/api/models'; +import { + errorCallState, + idleCallState, + pendingCallState, + successCallState, + toStoreError, + type CallState, +} from '@core/request-state'; +import { InventoryService } from '@features/organization/features/inventory/data-access'; +import type { + InventoryPartOutput, + InventoryWarehouseOutput, +} from '@features/organization/features/inventory/models'; + +/** + * Type InventoryPickerItem + * + * @description + * Authorized reference projection shared by the two inventory choice widgets. + * + * @type InventoryPickerItem + */ +export type InventoryPickerItem = InventoryPartOutput | InventoryWarehouseOutput; + +/** + * Interface InventoryPickerQuery + * @interface InventoryPickerQuery + * + * @description + * Immutable server scope and candidate-page query. + */ +export interface InventoryPickerQuery { + /** + * Property organizationId + * @readonly + * + * @description + * Organization authority for reference reads. + * + * @access public + * @since unreleased + * + * @type {string} + */ + readonly organizationId: string; + + /** + * Property kind + * @readonly + * + * @description + * Reference directory to query. + * + * @access public + * @since unreleased + * + * @type {'part' | 'warehouse'} + */ + readonly kind: 'part' | 'warehouse'; + + /** + * Property page + * @readonly + * + * @description + * Server page starting at one. + * + * @access public + * @since unreleased + * + * @type {number | undefined} + */ + readonly page?: number; + + /** + * Property search + * @readonly + * + * @description + * Search term resolved by the server across the whole directory. + * + * @access public + * @since unreleased + * + * @type {string | undefined} + */ + readonly search?: string; +} + +/** + * Interface InventoryPickerState + * @interface InventoryPickerState + * + * @description + * Independent candidate-list and selected-reference request states. + */ +interface InventoryPickerState { + /** + * Property query + * @readonly + * + * @description + * Current candidate scope. + * + * @access public + * @since unreleased + * + * @type {InventoryPickerQuery | null} + */ + readonly query: InventoryPickerQuery | null; + + /** + * Property listCallState + * @readonly + * + * @description + * Candidate page request state. + * + * @access public + * @since unreleased + * + * @type {CallState>} + */ + readonly listCallState: CallState; + + /** + * Property readCallState + * @readonly + * + * @description + * Selected reference hydration independent of candidate pages. + * + * @access public + * @since unreleased + * + * @type {CallState>} + */ + readonly readCallState: CallState; + + /** + * Property total + * @readonly + * + * @description + * Authoritative matching candidate count. + * + * @access public + * @since unreleased + * + * @type {number} + */ + readonly total: number; +} + +/** + * Constant initialState + * + * @description + * Empty scope does not imply a successfully loaded directory. + */ +const initialState: InventoryPickerState = { + query: null, + listCallState: idleCallState(), + readCallState: idleCallState(), + total: 0, +}; + +/** + * Constant InventoryPickerStore + * + * @description + * Component-scoped inventory reference lookup with cancellable server reads. + */ +export const InventoryPickerStore = signalStore( + withEntities({ entity: type(), collection: 'reference' }), + withState(initialState), + withComputed((store) => ({ + pageCount: computed(() => Math.max(1, Math.ceil(store.total() / 50))), + })), + withMethods((store, service = inject(InventoryService)) => { + const scopeReset = new Subject(); + return { + /** + * @description + * Loads active candidates only and cancels obsolete search or organization reads. + */ + load: rxMethod( + pipe( + switchMap((query) => { + if (!query) { + scopeReset.next(); + patchState(store, initialState, removeAllEntities({ collection: 'reference' })); + return EMPTY; + } + const sameScope = + store.query()?.organizationId === query.organizationId && + store.query()?.kind === query.kind; + if (!sameScope) scopeReset.next(); + patchState( + store, + ...(sameScope ? [] : [initialState]), + removeAllEntities({ collection: 'reference' }), + { query, total: 0, listCallState: pendingCallState() }, + ); + const options = { + page: query.page ?? 1, + itemsPerPage: 50, + search: query.search, + params: { archived: false }, + }; + const request: Observable> = + query.kind === 'part' + ? service.listParts(query.organizationId, options) + : service.listWarehouses(query.organizationId, options); + return request.pipe( + tapResponse({ + next: (response) => + patchState( + store, + setAllEntities( + response.member.filter((item) => !item.archived), + { collection: 'reference' }, + ), + { total: response.totalItems, listCallState: successCallState(null) }, + ), + error: (error: unknown) => + patchState(store, { listCallState: errorCallState(toStoreError(error)) }), + }), + ); + }), + ), + ), + /** + * @description + * Hydrates existing UUID values, including archived historical references. + */ + read: rxMethod<(InventoryPickerQuery & { readonly id: string }) | null>( + pipe( + switchMap((scope) => { + patchState(store, { readCallState: scope ? pendingCallState() : idleCallState() }); + if (!scope) return EMPTY; + const request: Observable = + scope.kind === 'part' + ? service.readPart(scope.organizationId, scope.id) + : service.readWarehouse(scope.organizationId, scope.id); + return request.pipe( + takeUntil(scopeReset), + tapResponse({ + next: (item) => patchState(store, { readCallState: successCallState(item) }), + error: (error: unknown) => + patchState(store, { readCallState: errorCallState(toStoreError(error)) }), + }), + ); + }), + ), + ), + }; + }), +); + +/** + * Type InventoryPickerStoreType + * + * @description + * Scoped reference-widget store instance. + * + * @type InventoryPickerStoreType + */ +export type InventoryPickerStoreType = InstanceType; diff --git a/src/app/features/organization/features/inventory/state/inventory-picker/testing/inventory-picker.store.spec.ts b/src/app/features/organization/features/inventory/state/inventory-picker/testing/inventory-picker.store.spec.ts new file mode 100644 index 000000000..a8011a5b3 --- /dev/null +++ b/src/app/features/organization/features/inventory/state/inventory-picker/testing/inventory-picker.store.spec.ts @@ -0,0 +1,109 @@ +import { TestBed } from '@angular/core/testing'; +import { of, Subject, throwError } from 'rxjs'; +import type { HydraCollection } from '@core/api/models'; +import { InventoryService } from '@features/organization/features/inventory/data-access'; +import type { + InventoryPartOutput, + InventoryWarehouseOutput, +} from '@features/organization/features/inventory/models'; +import { InventoryPickerStore } from '../inventory-picker.store'; + +describe('InventoryPickerStore', () => { + const part: InventoryPartOutput = { + '@id': '/part', + '@type': 'InventoryPart', + id: 'part-id', + code: 'SEAL', + label: 'Seal', + unit: 'piece', + kind: 'part', + archived: false, + }; + const warehouse: InventoryWarehouseOutput = { + '@id': '/warehouse', + '@type': 'InventoryWarehouse', + id: 'warehouse-id', + code: 'VAN', + name: 'Van', + archived: false, + }; + let service: { + listParts: ReturnType; + listWarehouses: ReturnType; + readPart: ReturnType; + readWarehouse: ReturnType; + }; + + beforeEach(() => { + service = { + listParts: vi.fn().mockReturnValue(of({ member: [part], totalItems: 151 })), + listWarehouses: vi.fn().mockReturnValue(of({ member: [warehouse], totalItems: 1 })), + readPart: vi.fn().mockReturnValue(of(part)), + readWarehouse: vi.fn().mockReturnValue(of(warehouse)), + }; + TestBed.configureTestingModule({ + providers: [InventoryPickerStore, { provide: InventoryService, useValue: service }], + }); + }); + + it('searches active server references in pages of 50 with exact totals', () => { + const store = TestBed.inject(InventoryPickerStore); + store.load({ organizationId: 'org', kind: 'part', page: 3, search: 'seal' }); + expect(service.listParts).toHaveBeenCalledWith('org', { + page: 3, + itemsPerPage: 50, + search: 'seal', + params: { archived: false }, + }); + expect(store.pageCount()).toBe(4); + expect(store.referenceEntities()).toEqual([part]); + expect(store.listCallState().status).toBe('success'); + }); + + it('hydrates archived selection independently of active search pages', () => { + service.readPart.mockReturnValue(of({ ...part, archived: true })); + const store = TestBed.inject(InventoryPickerStore); + store.load({ organizationId: 'org', kind: 'part', page: 2 }); + store.read({ organizationId: 'org', kind: 'part', id: 'old-part' }); + expect(service.readPart).toHaveBeenCalledWith('org', 'old-part'); + expect(store.readCallState().data?.archived).toBe(true); + expect(store.query()?.page).toBe(2); + }); + + it('cancels obsolete lists and selected hydration on scope reset', () => { + const list = new Subject>(); + const selected = new Subject(); + service.listParts.mockReturnValue(list); + service.readPart.mockReturnValue(selected); + const store = TestBed.inject(InventoryPickerStore); + store.load({ organizationId: 'old', kind: 'part' }); + store.read({ organizationId: 'old', kind: 'part', id: part.id }); + store.load(null); + store.load({ organizationId: 'new', kind: 'warehouse' }); + list.next({ '@context': '', '@id': '', '@type': 'Collection', member: [part], totalItems: 1 }); + selected.next(part); + expect(list.observed).toBe(false); + expect(selected.observed).toBe(false); + expect(store.referenceEntities()).toEqual([warehouse]); + expect(store.readCallState().status).toBe('idle'); + }); + + it('clears obsolete candidates while a new search is pending', () => { + const store = TestBed.inject(InventoryPickerStore); + store.load({ organizationId: 'org', kind: 'part' }); + service.listParts.mockReturnValue(new Subject()); + store.load({ organizationId: 'org', kind: 'part', search: 'new' }); + expect(store.referenceEntities()).toEqual([]); + expect(store.listCallState().status).toBe('pending'); + }); + + it('normalizes a selected-read failure without destroying the list', () => { + service.readWarehouse.mockReturnValue(throwError(() => new Error('Reference unavailable'))); + const store = TestBed.inject(InventoryPickerStore); + store.load({ organizationId: 'org', kind: 'warehouse' }); + store.read({ organizationId: 'org', kind: 'warehouse', id: 'missing' }); + expect(store.readCallState().status).toBe('error'); + expect(store.readCallState().error?.message).toBe('Reference unavailable'); + expect(store.referenceEntities()).toEqual([warehouse]); + }); +}); diff --git a/src/app/features/organization/features/inventory/state/inventory/events.ts b/src/app/features/organization/features/inventory/state/inventory/events.ts new file mode 100644 index 000000000..f3af4b050 --- /dev/null +++ b/src/app/features/organization/features/inventory/state/inventory/events.ts @@ -0,0 +1,19 @@ +import { type } from '@ngrx/signals'; +import { eventGroup } from '@ngrx/signals/events'; + +/** + * Constant inventoryEvents + * + * @description + * Confirmed commands trigger sibling reads and close only the originating editor. + */ +export const inventoryEvents = eventGroup({ + source: 'Inventory', + events: { + saved: type<{ + readonly organizationId: string; + readonly userId: string; + readonly kind: string; + }>(), + }, +}); diff --git a/src/app/features/organization/features/inventory/state/inventory/index.ts b/src/app/features/organization/features/inventory/state/inventory/index.ts new file mode 100644 index 000000000..de860f9d5 --- /dev/null +++ b/src/app/features/organization/features/inventory/state/inventory/index.ts @@ -0,0 +1,4 @@ +export { InventoryStore, type InventoryStoreType, type InventoryRecord } from './inventory.store'; +export type { InventoryQuery, InventorySection } from './inventory-query.interface'; +export type { InventoryCommand } from './inventory-command.type'; +export { inventoryEvents } from './events'; diff --git a/src/app/features/organization/features/inventory/state/inventory/inventory-command.type.ts b/src/app/features/organization/features/inventory/state/inventory/inventory-command.type.ts new file mode 100644 index 000000000..b3e7f7242 --- /dev/null +++ b/src/app/features/organization/features/inventory/state/inventory/inventory-command.type.ts @@ -0,0 +1,33 @@ +import type { + CreateInventoryPartInput, + CreateInventoryWarehouseInput, + UpdateInventoryPartInput, + UpdateInventoryWarehouseInput, + InventoryPhysicalCommand, +} from '@features/organization/features/inventory/models'; + +/** + * Type InventoryCommand + * + * @description + * Scoped administration commands and durable physical stock intentions. + * + * @type InventoryCommand + */ +export type InventoryCommand = + | InventoryPhysicalCommand + | ({ readonly organizationId: string; readonly userId: string } & ( + | { readonly kind: 'createPart'; readonly input: CreateInventoryPartInput } + | { readonly kind: 'createWarehouse'; readonly input: CreateInventoryWarehouseInput } + | { + readonly kind: 'updatePart'; + readonly id: string; + readonly input: UpdateInventoryPartInput; + } + | { + readonly kind: 'updateWarehouse'; + readonly id: string; + readonly input: UpdateInventoryWarehouseInput; + } + | { readonly kind: 'reconcile'; readonly id: string } + )); diff --git a/src/app/features/organization/features/inventory/state/inventory/inventory-query.interface.ts b/src/app/features/organization/features/inventory/state/inventory/inventory-query.interface.ts new file mode 100644 index 000000000..b32fa5e81 --- /dev/null +++ b/src/app/features/organization/features/inventory/state/inventory/inventory-query.interface.ts @@ -0,0 +1,138 @@ +import type { InventoryConsumptionStatus } from '@features/organization/features/inventory/models'; + +/** + * Type InventorySection + * + * @description + * Organization stock directory sections. + * + * @type InventorySection + */ +export type InventorySection = 'balances' | 'parts' | 'warehouses' | 'consumptions' | 'movements'; + +/** + * Interface InventoryQuery + * @interface InventoryQuery + * + * @description + * Server pagination and filters never masquerade as page-local matching. + */ +export interface InventoryQuery { + /** + * Property organizationId + * @readonly + * + * @description + * Organization authority. + * + * @access public + * @since unreleased + * + * @type {string} + */ + readonly organizationId: string; + /** + * Property userId + * @readonly + * + * @description + * Authenticated account owning pending physical commands. + * + * @access public + * @since unreleased + * + * @type {string} + */ + readonly userId: string; + /** + * Property section + * @readonly + * + * @description + * Requested directory. + * + * @access public + * @since unreleased + * + * @type {InventorySection} + */ + readonly section: InventorySection; + /** + * Property page + * @readonly + * + * @description + * Server page starting at one. + * + * @access public + * @since unreleased + * + * @type {number | undefined} + */ + readonly page?: number; + /** + * Property search + * @readonly + * + * @description + * Server reference search. + * + * @access public + * @since unreleased + * + * @type {string | undefined} + */ + readonly search?: string; + /** + * Property archived + * @readonly + * + * @description + * Reference archive filter. + * + * @access public + * @since unreleased + * + * @type {boolean | undefined} + */ + readonly archived?: boolean; + /** + * Property partId + * @readonly + * + * @description + * Optional exact part filter. + * + * @access public + * @since unreleased + * + * @type {string | undefined} + */ + readonly partId?: string; + /** + * Property warehouseId + * @readonly + * + * @description + * Optional exact warehouse filter. + * + * @access public + * @since unreleased + * + * @type {string | undefined} + */ + readonly warehouseId?: string; + /** + * Property status + * @readonly + * + * @description + * Optional declaration status. + * + * @access public + * @since unreleased + * + * @type {InventoryConsumptionStatus | undefined} + */ + readonly status?: InventoryConsumptionStatus; +} diff --git a/src/app/features/organization/features/inventory/state/inventory/inventory.store.ts b/src/app/features/organization/features/inventory/state/inventory/inventory.store.ts new file mode 100644 index 000000000..7b8a7525e --- /dev/null +++ b/src/app/features/organization/features/inventory/state/inventory/inventory.store.ts @@ -0,0 +1,382 @@ +import { computed, inject } from '@angular/core'; +import { tapResponse } from '@ngrx/operators'; +import { patchState, signalStore, withComputed, withMethods, withState } from '@ngrx/signals'; +import { + entityConfig, + setAllEntities, + upsertEntity, + removeEntity, + withEntities, +} from '@ngrx/signals/entities'; +import { Dispatcher } from '@ngrx/signals/events'; +import { rxMethod } from '@ngrx/signals/rxjs-interop'; +import { + EMPTY, + catchError, + defer, + exhaustMap, + from, + map, + mergeMap, + of, + pipe, + switchMap, + tap, + toArray, + type Observable, +} from 'rxjs'; +import type { RequestOptions } from '@core/api'; +import type { HydraCollection } from '@core/api/models'; +import { + errorCallState, + idleCallState, + pendingCallState, + successCallState, + toStoreError, + type CallState, +} from '@core/request-state'; +import { + InventoryService, + InventoryCommandRepository, +} from '@features/organization/features/inventory/data-access'; +import type { + InventoryPartOutput, + InventoryWarehouseOutput, + InventoryBalanceOutput, + InventoryConsumptionOutput, + InventoryMovementOutput, + InventoryPhysicalCommand, +} from '@features/organization/features/inventory/models'; +import { quantityOnlyInventoryRecord } from '@features/organization/features/inventory/utils'; +import { inventoryEvents } from './events'; +import type { InventoryCommand } from './inventory-command.type'; +import type { InventoryQuery } from './inventory-query.interface'; + +/** + * Type InventoryRecord + * + * @description + * Canonical records share stable server identities, with no financial projection. + * + * @type InventoryRecord + */ +export type InventoryRecord = + | InventoryPartOutput + | InventoryWarehouseOutput + | InventoryBalanceOutput + | InventoryConsumptionOutput + | InventoryMovementOutput; + +/** + * Constant RECORDS + * + * @description + * Current server page uses normalized entities. + */ +const RECORDS = entityConfig({ + entity: {} as InventoryRecord, + collection: 'record', + selectId: (record) => record.id, +}); +/** + * Constant PENDING + * + * @description + * Unacknowledged physical commands retain their immutable operation identity. + */ +const PENDING = entityConfig({ + entity: {} as InventoryPhysicalCommand, + collection: 'pendingCommand', + selectId: (command) => command.input.clientOperationId, +}); +/** + * Constant INITIAL + * + * @description + * Loading, storage and write states have independent explicit failure modes. + */ +const INITIAL = { + query: null as InventoryQuery | null, + scopeRevision: 0, + listCallState: idleCallState() as CallState, + writeCallState: idleCallState() as CallState, + journalCallState: idleCallState() as CallState, + total: 0, + partLabels: {} as Record, + partUnits: {} as Record, + warehouseLabels: {} as Record, +}; + +/** + * Constant InventoryStore + * + * @description + * Server-paginated quantity directories with session-fenced writes and explicit durable replay. + */ +export const InventoryStore = signalStore( + withEntities(RECORDS), + withEntities(PENDING), + withState(INITIAL), + withComputed((store) => ({ + pageCount: computed(() => Math.max(1, Math.ceil(store.total() / 20))), + })), + withMethods((store, service = inject(InventoryService)) => ({ + /** + * Method load + * + * @description + * Cancels obsolete reads; bounded reference hydration keeps stock facts recognizable. + * + * @param {InventoryQuery | null} query - Authorized scope and server filters. + */ + load: rxMethod( + pipe( + switchMap((query) => { + const old = store.query(); + const changed = + !query || old?.organizationId !== query.organizationId || old?.userId !== query.userId; + if (changed) + patchState( + store, + INITIAL, + { scopeRevision: store.scopeRevision() + 1 }, + setAllEntities([] as InventoryRecord[], RECORDS), + setAllEntities([] as InventoryPhysicalCommand[], PENDING), + ); + if (!query) return EMPTY; + patchState( + store, + { query, listCallState: pendingCallState() }, + setAllEntities([] as InventoryRecord[], RECORDS), + ); + const options: RequestOptions = { + page: query.page ?? 1, + itemsPerPage: 20, + search: + query.section === 'parts' || query.section === 'warehouses' + ? query.search + : undefined, + params: { + ...(query.section === 'parts' || query.section === 'warehouses' + ? { archived: query.archived ?? false } + : {}), + ...(query.partId ? { partId: query.partId } : {}), + ...(query.warehouseId ? { warehouseId: query.warehouseId } : {}), + ...(query.section === 'consumptions' && query.status ? { status: query.status } : {}), + }, + }; + const request: Observable> = + query.section === 'parts' + ? service.listParts(query.organizationId, options) + : query.section === 'warehouses' + ? service.listWarehouses(query.organizationId, options) + : query.section === 'balances' + ? service.listBalances(query.organizationId, options) + : query.section === 'consumptions' + ? service.listConsumptions(query.organizationId, options) + : service.listMovements(query.organizationId, options); + return request.pipe( + switchMap((response) => { + const missing = new Map(); + for (const entry of response.member) { + if ('partId' in entry && !store.partLabels()[entry.partId]) + missing.set('part:' + entry.partId, { kind: 'part', id: entry.partId }); + if ('warehouseId' in entry && !store.warehouseLabels()[entry.warehouseId]) + missing.set('warehouse:' + entry.warehouseId, { + kind: 'warehouse', + id: entry.warehouseId, + }); + } + return from(missing.values()).pipe( + mergeMap((ref) => { + const read: Observable = + ref.kind === 'part' + ? service.readPart(query.organizationId, ref.id) + : service.readWarehouse(query.organizationId, ref.id); + return read.pipe( + map((entry) => ({ ref, entry })), + catchError(() => of(null)), + ); + }, 4), + toArray(), + map((labels) => ({ response, labels })), + ); + }), + tapResponse({ + next: ({ response, labels }) => { + const partLabels = { ...store.partLabels() }, + partUnits = { ...store.partUnits() }, + warehouseLabels = { ...store.warehouseLabels() }; + for (const result of labels) { + if (!result) continue; + const { ref, entry } = result; + if ('label' in entry) { + partLabels[ref.id] = entry.label; + partUnits[ref.id] = entry.unit; + } else warehouseLabels[ref.id] = entry.name; + } + patchState( + store, + setAllEntities(response.member.map(quantityOnlyInventoryRecord), RECORDS), + { + total: response.totalItems, + partLabels, + partUnits, + warehouseLabels, + listCallState: successCallState(null), + }, + ); + }, + error: (error: unknown) => + patchState(store, { listCallState: errorCallState(toStoreError(error)) }), + }), + ); + }), + ), + ), + /** + * Method clearWrite + * + * @description + * Clears feedback when opening a new editor without cancelling an accepted command. + * + * @access public + * @since unreleased + * + * @returns {void} + */ + clearWrite(): void { + if (store.writeCallState().status !== 'pending') + patchState(store, { writeCallState: idleCallState() }); + }, + })), + withMethods((store, journal = inject(InventoryCommandRepository)) => ({ + /** + * Method loadJournal + * + * @description + * Reads durable unacknowledged commands for explicit operator replay. + * + * @param {{ userId: string; organizationId: string } | null} scope - Current account and + * organization. + */ + loadJournal: rxMethod<{ userId: string; organizationId: string } | null>( + pipe( + switchMap((scope) => { + patchState(store, setAllEntities([] as InventoryPhysicalCommand[], PENDING), { + journalCallState: scope ? pendingCallState() : idleCallState(), + }); + if (!scope) return EMPTY; + return from(journal.readPending(scope.userId, scope.organizationId)).pipe( + tapResponse({ + next: (commands) => + patchState(store, setAllEntities([...commands], PENDING), { + journalCallState: successCallState(null), + }), + error: (error: unknown) => + patchState(store, { journalCallState: errorCallState(toStoreError(error)) }), + }), + ); + }), + ), + ), + })), + withMethods( + ( + store, + service = inject(InventoryService), + journal = inject(InventoryCommandRepository), + dispatcher = inject(Dispatcher), + ) => ({ + /** + * Method save + * + * @description + * Serializes accepted writes, stores physical intentions first and suppresses + * obsolete-session effects. + * + * @param {InventoryCommand} command - Explicit scoped command. + */ + save: rxMethod( + pipe( + exhaustMap((command) => { + const scope = store.query(); + if (scope?.organizationId !== command.organizationId || scope.userId !== command.userId) + return EMPTY; + const revision = store.scopeRevision(); + const sessionRevision = journal.sessionRevision(); + const current = () => + store.scopeRevision() === revision && + store.query()?.organizationId === command.organizationId && + store.query()?.userId === command.userId && + journal.isCurrent(command.userId, command.organizationId, sessionRevision); + if (!current()) return EMPTY; + patchState(store, { writeCallState: pendingCallState() }); + const physical = + command.kind === 'correction' || command.kind === 'return' ? command : null; + const request = (): Observable => { + switch (command.kind) { + case 'createPart': + return service.createPart(command.organizationId, command.input); + case 'createWarehouse': + return service.createWarehouse(command.organizationId, command.input); + case 'updatePart': + return service.updatePart(command.organizationId, command.id, command.input); + case 'updateWarehouse': + return service.updateWarehouse(command.organizationId, command.id, command.input); + case 'reconcile': + return service.reconcileConsumption(command.organizationId, command.id); + case 'return': + return service.returnConsumption(command.organizationId, command.input); + case 'correction': + return service.correctStock(command.organizationId, command.input); + } + }; + return defer(() => (physical ? from(journal.retain(physical)) : of(undefined))).pipe( + tap(() => { + if (physical && current()) patchState(store, upsertEntity(physical, PENDING)); + }), + switchMap(() => (current() ? request() : EMPTY)), + switchMap((result) => + physical && current() + ? from(journal.acknowledge(physical, sessionRevision)).pipe(map(() => result)) + : of(result), + ), + tapResponse({ + next: (result) => { + if (!current()) return; + patchState( + store, + ...(physical ? [removeEntity(physical.input.clientOperationId, PENDING)] : []), + { writeCallState: successCallState(quantityOnlyInventoryRecord(result)) }, + ); + dispatcher.dispatch( + inventoryEvents.saved({ + organizationId: command.organizationId, + userId: command.userId, + kind: command.kind, + }), + ); + }, + error: (error: unknown) => { + if (current()) + patchState(store, { writeCallState: errorCallState(toStoreError(error)) }); + }, + }), + ); + }), + ), + ), + }), + ), +); + +/** + * Type InventoryStoreType + * + * @description + * Injectable page-owned stock state. + * + * @type InventoryStoreType + */ +export type InventoryStoreType = InstanceType; diff --git a/src/app/features/organization/features/inventory/state/inventory/testing/inventory.store.spec.ts b/src/app/features/organization/features/inventory/state/inventory/testing/inventory.store.spec.ts new file mode 100644 index 000000000..7b35a9046 --- /dev/null +++ b/src/app/features/organization/features/inventory/state/inventory/testing/inventory.store.spec.ts @@ -0,0 +1,330 @@ +import { TestBed } from '@angular/core/testing'; +import { Dispatcher } from '@ngrx/signals/events'; +import { of, Subject, throwError } from 'rxjs'; +import { + InventoryService, + InventoryCommandRepository, +} from '@features/organization/features/inventory/data-access'; +import type { + InventoryPartOutput, + InventoryWarehouseOutput, + InventoryBalanceOutput, + InventoryMovementOutput, + InventoryConsumptionOutput, + InventoryPhysicalCommand, +} from '@features/organization/features/inventory/models'; +import { InventoryStore } from '../inventory.store'; + +async function flush() { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); +} + +describe('InventoryStore', () => { + const part: InventoryPartOutput = { + '@id': '/part', + '@type': 'InventoryPart', + id: 'part', + code: 'SEAL', + label: 'Seal', + unit: 'piece', + kind: 'part', + archived: true, + }; + const warehouse: InventoryWarehouseOutput = { + '@id': '/warehouse', + '@type': 'InventoryWarehouse', + id: 'warehouse', + code: 'VAN', + name: 'Van', + archived: true, + }; + const balance: InventoryBalanceOutput = { + '@id': '/balance', + '@type': 'InventoryBalance', + id: 'balance', + partId: 'part', + warehouseId: 'warehouse', + quantity: '999999999999999999.123456', + }; + const movement: InventoryMovementOutput = { + '@id': '/movement', + '@type': 'InventoryMovement', + id: 'movement', + partId: 'part', + warehouseId: 'warehouse', + kind: 'correction', + quantity: '-1.000001', + reason: 'Physical count', + actorId: 'actor', + occurredAt: '2026-10-06T12:00:00Z', + late: false, + replayed: false, + }; + const consumption: InventoryConsumptionOutput = { + '@id': '/consumption', + '@type': 'InventoryConsumption', + id: 'consumption', + partId: 'part', + warehouseId: 'warehouse', + quantity: '3.000000', + interventionId: 'intervention', + actorId: 'actor', + occurredAt: '2026-10-06T12:00:00Z', + status: 'received_pending', + reason: 'insufficient_stock', + late: false, + replayed: false, + }; + const physical: InventoryPhysicalCommand = { + kind: 'correction', + organizationId: 'org', + userId: 'user', + input: { + clientOperationId: 'stable', + partId: 'part', + warehouseId: 'warehouse', + quantity: '-1.000001', + reason: 'Physical count', + }, + }; + function setup() { + let revision = 1; + const service = { + listParts: vi.fn().mockReturnValue(of({ member: [part], totalItems: 41 })), + listWarehouses: vi.fn().mockReturnValue(of({ member: [warehouse], totalItems: 1 })), + listBalances: vi.fn().mockReturnValue(of({ member: [balance], totalItems: 1 })), + listMovements: vi.fn().mockReturnValue(of({ member: [movement], totalItems: 1 })), + listConsumptions: vi.fn().mockReturnValue(of({ member: [consumption], totalItems: 1 })), + readPart: vi.fn().mockReturnValue(of(part)), + readWarehouse: vi.fn().mockReturnValue(of(warehouse)), + createPart: vi.fn().mockReturnValue(of(part)), + updatePart: vi.fn().mockReturnValue(of(part)), + createWarehouse: vi.fn().mockReturnValue(of(warehouse)), + updateWarehouse: vi.fn().mockReturnValue(of(warehouse)), + reconcileConsumption: vi.fn().mockReturnValue(of(consumption)), + correctStock: vi.fn().mockReturnValue(of(movement)), + returnConsumption: vi.fn().mockReturnValue(of(movement)), + }; + const journal = { + sessionRevision: () => revision, + isCurrent: vi.fn((_user: string, _org: string, expected = revision) => revision === expected), + readPending: vi.fn().mockResolvedValue([]), + retain: vi.fn().mockResolvedValue(undefined), + acknowledge: vi.fn().mockResolvedValue(undefined), + }; + const dispatcher = { dispatch: vi.fn() }; + TestBed.configureTestingModule({ + providers: [ + InventoryStore, + { provide: InventoryService, useValue: service }, + { provide: InventoryCommandRepository, useValue: journal }, + { provide: Dispatcher, useValue: dispatcher }, + ], + }); + const store = TestBed.inject(InventoryStore); + const activate = () => store.load({ organizationId: 'org', userId: 'user', section: 'parts' }); + return { + store, + service, + journal, + dispatcher, + activate, + replaceSession: () => { + revision++; + }, + }; + } + it('uses authoritative reference filters and exact server pagination totals', () => { + const { store, service } = setup(); + store.load({ + organizationId: 'org', + userId: 'user', + section: 'parts', + search: 'seal', + archived: true, + page: 2, + }); + expect(service.listParts).toHaveBeenCalledWith('org', { + page: 2, + itemsPerPage: 20, + search: 'seal', + params: { archived: true }, + }); + expect(store.pageCount()).toBe(3); + expect(store.recordEntities()).toEqual([part]); + }); + it('keeps quantities exact and hydrates retained archived identities outside active directory filters', () => { + const { store, service } = setup(); + store.load({ + organizationId: 'org', + userId: 'user', + section: 'balances', + partId: 'part', + warehouseId: 'warehouse', + }); + expect(service.listBalances).toHaveBeenCalledWith( + 'org', + expect.objectContaining({ params: { partId: 'part', warehouseId: 'warehouse' } }), + ); + expect(store.recordEntities()[0]).toEqual(balance); + expect(store.partLabels()['part']).toBe('Seal'); + expect(store.partUnits()['part']).toBe('piece'); + expect(store.warehouseLabels()['warehouse']).toBe('Van'); + expect(service.readPart).toHaveBeenCalledWith('org', 'part'); + expect(service.listParts).not.toHaveBeenCalled(); + }); + it('does not discard stock facts when an old reference cannot be hydrated', () => { + const { store, service } = setup(); + service.readPart.mockReturnValue(throwError(() => new Error('Reference unavailable'))); + store.load({ organizationId: 'org', userId: 'user', section: 'balances' }); + expect(store.recordEntities()).toEqual([balance]); + expect(store.listCallState().status).toBe('success'); + expect(store.partLabels()['part']).toBeUndefined(); + }); + it('strips financial valuation from ordinary reads and write responses', async () => { + const { store, service, activate } = setup(); + service.listMovements.mockReturnValue( + of({ + member: [ + { + ...movement, + valuation: { + unitCost: '9.000000', + totalValue: '-9.000009', + currency: 'EUR', + incomplete: false, + }, + }, + ], + totalItems: 1, + }), + ); + store.load({ organizationId: 'org', userId: 'user', section: 'movements' }); + expect(store.recordEntities()[0]).not.toHaveProperty('valuation'); + activate(); + service.correctStock.mockReturnValue( + of({ ...movement, valuation: { totalValue: '-9.000009' } }), + ); + store.save(physical); + await flush(); + expect(store.writeCallState().data).not.toHaveProperty('valuation'); + }); + it('cancels old directory responses and clears entity identities on organization changes', () => { + const { store, service } = setup(); + const old = new Subject<{ member: readonly InventoryPartOutput[]; totalItems: number }>(); + service.listParts.mockReturnValueOnce(old); + store.load({ organizationId: 'old', userId: 'user', section: 'parts' }); + store.load({ organizationId: 'new', userId: 'user', section: 'warehouses' }); + old.next({ member: [part], totalItems: 100 }); + expect(store.recordEntities()).toEqual([warehouse]); + expect(store.total()).toBe(1); + expect(store.partLabels()).toEqual({}); + }); + it('normalizes a read rejection and supports explicit retry', () => { + const { store, service, activate } = setup(); + service.listParts.mockReturnValueOnce(throwError(() => new Error('Network unavailable'))); + activate(); + expect(store.listCallState().error?.message).toBe('Network unavailable'); + activate(); + expect(store.listCallState().status).toBe('success'); + }); + it('persists a stable physical command before POST and drops duplicate clicks while in flight', async () => { + const { store, service, journal, activate } = setup(); + activate(); + let commit!: () => void; + journal.retain.mockReturnValue( + new Promise((resolve) => { + commit = resolve; + }), + ); + const response = new Subject(); + service.correctStock.mockReturnValue(response); + store.save(physical); + store.save(physical); + expect(journal.retain).toHaveBeenCalledTimes(1); + expect(service.correctStock).not.toHaveBeenCalled(); + commit(); + await flush(); + expect(service.correctStock).toHaveBeenCalledTimes(1); + expect(store.pendingCommandEntities()).toEqual([physical]); + expect(store.writeCallState().status).toBe('pending'); + response.next(movement); + await flush(); + expect(journal.acknowledge).toHaveBeenCalledWith(physical, 1); + expect(store.pendingCommandEntities()).toEqual([]); + }); + it('never transmits when durable local storage rejects the physical command', async () => { + const { store, service, journal, activate } = setup(); + journal.retain.mockRejectedValue(new Error('Storage full')); + activate(); + store.save(physical); + await flush(); + expect(service.correctStock).not.toHaveBeenCalled(); + expect(store.writeCallState().error?.message).toBe('Storage full'); + expect(store.pendingCommandEntities()).toEqual([]); + }); + it('keeps the complete immutable intention after a lost response and replays only that UUID and body', async () => { + const { store, service, journal, activate } = setup(); + service.correctStock.mockReturnValueOnce(throwError(() => new Error('Response lost'))); + activate(); + store.save(physical); + await flush(); + expect(store.pendingCommandEntities()).toEqual([physical]); + expect(journal.acknowledge).not.toHaveBeenCalled(); + store.save(physical); + await flush(); + expect(service.correctStock.mock.calls[1]?.[1]).toBe(service.correctStock.mock.calls[0]?.[1]); + expect(store.pendingCommandEntities()).toEqual([]); + }); + it('does not send an old durable acceptance after the same account starts a different session', async () => { + const { store, service, journal, activate, replaceSession } = setup(); + activate(); + let commit!: () => void; + journal.retain.mockReturnValue( + new Promise((resolve) => { + commit = resolve; + }), + ); + store.save(physical); + replaceSession(); + commit(); + await flush(); + expect(service.correctStock).not.toHaveBeenCalled(); + expect(journal.acknowledge).not.toHaveBeenCalled(); + }); + it('suppresses old network acknowledgment and events after organization or session changes', async () => { + const { store, service, journal, dispatcher, activate, replaceSession } = setup(); + activate(); + const response = new Subject(); + service.correctStock.mockReturnValue(response); + store.save(physical); + await flush(); + replaceSession(); + store.load({ organizationId: 'other', userId: 'user', section: 'warehouses' }); + response.next(movement); + await flush(); + expect(journal.acknowledge).not.toHaveBeenCalled(); + expect(dispatcher.dispatch).not.toHaveBeenCalled(); + expect(store.recordEntities()).toEqual([warehouse]); + }); + it('reads local unresolved operations without automatically replaying them', async () => { + const { store, journal, service } = setup(); + journal.readPending.mockResolvedValue([physical]); + store.loadJournal({ organizationId: 'org', userId: 'user' }); + await flush(); + expect(store.pendingCommandEntities()).toEqual([physical]); + expect(service.correctStock).not.toHaveBeenCalled(); + }); + it('does not treat a still-pending reconciliation response as a debit or finished task', async () => { + const { store, service, journal, activate } = setup(); + activate(); + store.save({ kind: 'reconcile', organizationId: 'org', userId: 'user', id: consumption.id }); + await flush(); + expect(service.reconcileConsumption).toHaveBeenCalledWith('org', consumption.id); + expect(store.writeCallState().data).toEqual(consumption); + expect(journal.retain).not.toHaveBeenCalled(); + expect(journal.acknowledge).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/features/organization/features/inventory/ui/components/index.ts b/src/app/features/organization/features/inventory/ui/components/index.ts new file mode 100644 index 000000000..7867f5b49 --- /dev/null +++ b/src/app/features/organization/features/inventory/ui/components/index.ts @@ -0,0 +1,3 @@ +export { InventoryPartPicker } from './inventory-part-picker'; +export { InventoryWarehousePicker } from './inventory-warehouse-picker'; +export { InventoryConsumptionPanel } from './inventory-consumption-panel'; diff --git a/src/app/features/organization/features/inventory/ui/components/inventory-consumption-panel/index.ts b/src/app/features/organization/features/inventory/ui/components/inventory-consumption-panel/index.ts new file mode 100644 index 000000000..8e62278e3 --- /dev/null +++ b/src/app/features/organization/features/inventory/ui/components/inventory-consumption-panel/index.ts @@ -0,0 +1 @@ +export { InventoryConsumptionPanel } from './inventory-consumption-panel.component'; diff --git a/src/app/features/organization/features/inventory/ui/components/inventory-consumption-panel/inventory-consumption-panel.component.html b/src/app/features/organization/features/inventory/ui/components/inventory-consumption-panel/inventory-consumption-panel.component.html new file mode 100644 index 000000000..74916c000 --- /dev/null +++ b/src/app/features/organization/features/inventory/ui/components/inventory-consumption-panel/inventory-consumption-panel.component.html @@ -0,0 +1,114 @@ +
+ + @if (retainedCommand()) { +

+ The declaration is retained here until it is safely queued. Retry keeps the same recorded + facts. +

+ @if (mayRetry()) { +
+ +
+ } + } +
+

+ Consumption history +

+

+ These states describe stock recording. They do not indicate completed work or a validated + dossier. +

+
+ @if (localRows().length === 0 && declarationRows().length === 0) { +

+ No consumption has been declared for this work. +

+ } +
+ @for (row of localRows(); track row.intent.input.clientOperationId) { +
+
+
{{ row.title }}
+

+ {{ row.intent.input.quantity }} · {{ row.warehouse }} · + {{ row.intent.input.occurredAt | appOrgDate: 'datetime' : regionalFormatting() }} +

+ {{ row.label }} + @if (row.intent.error) { +

{{ row.intent.error }}

+ } +
+ @if (row.retryable && canDeclare()) { +
+ +
+ } +
+ + } + @for (row of declarationRows(); track row.id) { +
+
+
{{ row.title }}
+

+ {{ row.quantity }} · {{ row.warehouse }} · + {{ row.occurredAt | appOrgDate: 'datetime' : regionalFormatting() }} +

+ {{ row.label }} + @if (row.reason) { +

{{ row.reason }}

+ } + @if (row.late) { +

+ Recorded after publication; the published dossier remains unchanged. +

+ } +
+
+ @if (!$last) { + + } + } +
+
diff --git a/src/app/features/organization/features/inventory/ui/components/inventory-consumption-panel/inventory-consumption-panel.component.ts b/src/app/features/organization/features/inventory/ui/components/inventory-consumption-panel/inventory-consumption-panel.component.ts new file mode 100644 index 000000000..0dc337732 --- /dev/null +++ b/src/app/features/organization/features/inventory/ui/components/inventory-consumption-panel/inventory-consumption-panel.component.ts @@ -0,0 +1,688 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + input, + output, + signal, + untracked, + type InputSignal, + type OutputEmitterRef, + type Signal, + type WritableSignal, +} from '@angular/core'; +import type { + DeclareInventoryConsumptionInput, + InventoryConsumptionIntent, + InventoryConsumptionOutput, + InventoryPartOutput, + InventoryWarehouseOutput, +} from '@features/organization/features/inventory/models'; +import { InventoryConsumptionForm } from '@features/organization/features/inventory/ui/forms/inventory-consumption-form'; +import { + DEFAULT_REGIONAL_FORMAT_SETTINGS, + OrgDatePipe, + type RegionalFormatSettings, +} from '@shared/regional-format'; +import { HlmAlertImports } from '@shared/ui/alert'; +import { HlmBadge } from '@shared/ui/badge'; +import { HlmButton } from '@shared/ui/button'; +import { HlmItemImports } from '@shared/ui/item'; + +/** + * Class InventoryConsumptionPanel + * @class InventoryConsumptionPanel + * + * @description + * Presents factual consumption states and immutable intent; the caller owns durable outbox and + * transport. + */ +@Component({ + selector: 'app-inventory-consumption-panel', + templateUrl: './inventory-consumption-panel.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + InventoryConsumptionForm, + OrgDatePipe, + HlmBadge, + HlmButton, + ...HlmItemImports, + ...HlmAlertImports, + ], +}) +export class InventoryConsumptionPanel { + //#region Properties + /** + * Property organizationId + * @readonly + * + * @description + * Immutable organization authority captured with each emitted command. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly organizationId: InputSignal = input(''); + + /** + * Property interventionId + * @readonly + * + * @description + * Owning intervention for new declarations and visible history. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly interventionId: InputSignal = input(''); + + /** + * Property workItemId + * @readonly + * + * @description + * Optional work-item context restricting history and new declarations. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly workItemId: InputSignal = input(null); + + /** + * Property equipmentId + * @readonly + * + * @description + * Optional equipment context for factual usage. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly equipmentId: InputSignal = input(null); + + /** + * Property parts + * @readonly + * + * @description + * Authorized cached parts supplied by the parent, including offline choices. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly parts: InputSignal = input< + readonly InventoryPartOutput[] + >([]); + + /** + * Property warehouses + * @readonly + * + * @description + * Authorized cached warehouse references supplied by the parent. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly warehouses: InputSignal = input< + readonly InventoryWarehouseOutput[] + >([]); + + /** + * Property declarations + * @readonly + * + * @description + * Server-received physical facts with stock confirmation state. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly declarations: InputSignal = input< + readonly InventoryConsumptionOutput[] + >([]); + + /** + * Property localIntents + * @readonly + * + * @description + * Parent-owned durable local queue projection; not server stock confirmation. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly localIntents: InputSignal = input< + readonly InventoryConsumptionIntent[] + >([]); + + /** + * Property acceptedOperationId + * @readonly + * + * @description + * Explicit acknowledgment of durable local acceptance, correlated by client operation UUID. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly acceptedOperationId: InputSignal = input(null); + + /** + * Property pending + * @readonly + * + * @description + * Owner submission progress; local protection also prevents same-turn repeated clicks. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly pending: InputSignal = input(false); + + /** + * Property error + * @readonly + * + * @description + * Owner rejection displayed without changing the retained command. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly error: InputSignal = input(null); + + /** + * Property canDeclare + * @readonly + * + * @description + * Caller-owned execution and inventory permission gate. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly canDeclare: InputSignal = input(true); + + /** + * Property regionalFormatting + * @readonly + * + * @description + * Organization-owned timezone and formatting for recorded instants. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly regionalFormatting: InputSignal = input( + DEFAULT_REGIONAL_FORMAT_SETTINGS, + ); + + /** + * Property declared + * @readonly + * + * @description + * Immutable intent; retry emits the same operation UUID and physical facts. + * + * @access public + * @since unreleased + * + * @type {OutputEmitterRef} + */ + public readonly declared: OutputEmitterRef = + output(); + + /** + * Property retainedCommand + * @readonly + * + * @description + * Accepted submit retained until durable owner acknowledgment, including lost replies. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal} + */ + protected readonly retainedCommand: WritableSignal = + signal(null); + + /** + * Property resetKey + * @readonly + * + * @description + * Form reset advances only once for an explicitly acknowledged command. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal} + */ + protected readonly resetKey: WritableSignal = signal(0); + + /** + * Property inFlight + * @readonly + * + * @description + * Synchronous emission lock closes the gap before the owner updates its pending input. + * + * @access private + * @since unreleased + * + * @type {WritableSignal} + */ + private readonly inFlight: WritableSignal = signal(false); + + /** + * Property retryingOperationId + * @readonly + * + * @description + * Locks repeated replay clicks until the durable queue reflects transmission progress. + * + * @access private + * @since unreleased + * + * @type {WritableSignal} + */ + private readonly retryingOperationId: WritableSignal = signal(null); + + /** + * Property commandOrganizationId + * + * @description + * Original organization never changes when an uncertain command survives a context switch. + * + * @access private + * @since unreleased + * + * @type {string} + */ + private commandOrganizationId: string = ''; + + /** + * Property formLocked + * @readonly + * + * @description + * Entered command facts remain immutable while awaiting durable acknowledgment. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly formLocked: Signal = computed( + () => this.retainedCommand() !== null || !this.canDeclare(), + ); + + /** + * Property mayRetry + * @readonly + * + * @description + * Explicit retry remains within the original scope and waits for a known owner error. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly mayRetry: Signal = computed(() => { + const command = this.retainedCommand(); + return ( + !!command && + !!this.error() && + !this.pending() && + !this.inFlight() && + this.canDeclare() && + command.interventionId === this.interventionId() && + this.commandOrganizationId === this.organizationId() && + (command.workItemId ?? null) === this.workItemId() && + (command.equipmentId ?? null) === this.equipmentId() + ); + }); + + /** + * Property declarationRows + * @readonly + * + * @description + * Server state labels remain separate from work completion and dossier validation. + * + * @access protected + * @since unreleased + * + * @type {Signal< + * readonly { + * id: string; + * title: string; + * warehouse: string; + * quantity: string; + * occurredAt: string; + * label: string; + * reason: string | null; + * late: boolean; + * }[] + * >} + */ + protected readonly declarationRows: Signal< + readonly { + id: string; + title: string; + warehouse: string; + quantity: string; + occurredAt: string; + label: string; + reason: string | null; + late: boolean; + }[] + > = computed(() => + this.declarations() + .filter((item) => this.inContext(item)) + .map((item) => ({ + id: item.id, + title: this.partTitle(item.partId), + warehouse: this.warehouseTitle(item.warehouseId), + quantity: item.quantity, + occurredAt: item.occurredAt, + label: + item.status === 'confirmed' + ? $localize`:@@inventory.consumption.confirmed:Stock debit confirmed` + : $localize`:@@inventory.consumption.receivedPending:Received — reconciliation needed`, + reason: + item.reason === 'missing_balance' + ? $localize`:@@inventory.consumption.missingBalance:No stock balance is recorded for this source.` + : item.reason === 'archived_reference' + ? $localize`:@@inventory.consumption.archivedReference:The part or warehouse is archived.` + : item.reason === 'insufficient_stock' + ? $localize`:@@inventory.consumption.insufficientStock:Recorded stock is insufficient for the full quantity.` + : null, + late: item.late, + })), + ); + + /** + * Property localRows + * @readonly + * + * @description + * Durable local intents retain their identity without implying any confirmed stock debit. + * + * @access protected + * @since unreleased + * + * @type {Signal< + * readonly { + * intent: InventoryConsumptionIntent; + * title: string; + * warehouse: string; + * label: string; + * retryable: boolean; + * }[] + * >} + */ + protected readonly localRows: Signal< + readonly { + intent: InventoryConsumptionIntent; + title: string; + warehouse: string; + label: string; + retryable: boolean; + }[] + > = computed(() => + this.localIntents() + .filter((intent) => this.inContext(intent.input)) + .map((intent) => ({ + intent, + title: this.partTitle(intent.input.partId), + warehouse: this.warehouseTitle(intent.input.warehouseId), + label: + intent.status === 'sending' + ? $localize`:@@inventory.consumption.sending:Sending — stock not confirmed` + : intent.status === 'failed' + ? $localize`:@@inventory.consumption.localFailed:Queued locally — retry needed` + : $localize`:@@inventory.consumption.queued:Queued locally — awaiting synchronization`, + retryable: + intent.status === 'failed' && + this.retryingOperationId() !== intent.input.clientOperationId, + })), + ); + //#endregion + + //#region Constructor + /** + * Constructor + * @constructor + * + * @description + * Correlates durable acceptance using operation identity, never unrelated server IDs. + * + * @access public + * @since unreleased + */ + public constructor() { + effect(() => { + const command = this.retainedCommand(); + const acceptedOperationId = this.acceptedOperationId(); + const localIntents = this.localIntents(); + if ( + !command || + (acceptedOperationId !== command.clientOperationId && + !localIntents.some( + (intent) => intent.input.clientOperationId === command.clientOperationId, + )) + ) + return; + untracked(() => { + this.retainedCommand.set(null); + this.inFlight.set(false); + this.resetKey.update((value) => value + 1); + }); + }); + effect(() => { + const pending = this.pending(); + const error = this.error(); + if (!pending && error) untracked(() => this.inFlight.set(false)); + }); + effect(() => { + const intents = this.localIntents(); + untracked(() => { + const operationId = this.retryingOperationId(); + if ( + operationId && + !intents.some( + (intent) => + intent.input.clientOperationId === operationId && intent.status === 'failed', + ) + ) + this.retryingOperationId.set(null); + }); + }); + } + //#endregion + + //#region Methods + /** + * Method declare + * @method declare + * + * @description + * Captures context, timestamp and UUID once before emitting an accepted user intent. + * + * @access protected + * @since unreleased + * + * @param {Pick} draft - + * Validated consumption draft. + * + * @returns {void} No return value. + */ + protected declare( + draft: Pick, + ): void { + if ( + !this.canDeclare() || + this.pending() || + this.inFlight() || + this.retainedCommand() || + !this.organizationId() || + !this.interventionId() + ) + return; + const command: DeclareInventoryConsumptionInput = Object.freeze({ + ...draft, + clientOperationId: crypto.randomUUID(), + interventionId: this.interventionId(), + workItemId: this.workItemId(), + equipmentId: this.equipmentId(), + occurredAt: new Date().toISOString(), + }); + this.commandOrganizationId = this.organizationId(); + this.retainedCommand.set(command); + this.inFlight.set(true); + this.declared.emit(command); + } + + /** + * Method retry + * @method retry + * + * @description + * Re-emits the retained physical fact exactly, without a new UUID or timestamp. + * + * @access protected + * @since unreleased + * + * @returns {void} No return value. + */ + protected retry(): void { + const command = this.retainedCommand(); + if (!command || !this.mayRetry()) return; + this.inFlight.set(true); + this.declared.emit(command); + } + + /** + * Method retryIntent + * @method retryIntent + * + * @description + * Requests replay of a parent-owned failed durable intent with its original identity. + * + * @access protected + * @since unreleased + * + * @param {InventoryConsumptionIntent} intent - Failed durable queue entry supplied by the owner. + * + * @returns {void} No return value. + */ + protected retryIntent(intent: InventoryConsumptionIntent): void { + if ( + !this.canDeclare() || + this.pending() || + this.inFlight() || + this.retryingOperationId() === intent.input.clientOperationId || + intent.status !== 'failed' || + !this.inContext(intent.input) + ) + return; + this.retryingOperationId.set(intent.input.clientOperationId); + this.declared.emit(intent.input); + } + + /** + * Method inContext + * @method inContext + * + * @description + * Prevents a supplied broader collection from leaking another intervention's history. + * + * @access private + * @since unreleased + * + * @param {Pick< + * DeclareInventoryConsumptionInput, + * 'interventionId' | 'workItemId' | 'equipmentId' + * >} item + * - Declaration context. + * + * @returns {boolean} Whether the declaration belongs to the rendered scope. + */ + private inContext( + item: Pick, + ): boolean { + return ( + item.interventionId === this.interventionId() && + (!this.workItemId() || item.workItemId === this.workItemId()) && + (!this.equipmentId() || item.equipmentId === this.equipmentId()) + ); + } + + /** + * Method partTitle + * @method partTitle + * + * @description + * Resolves authorized cached labels and keeps an unknown reference identifiable. + * + * @access private + * @since unreleased + * + * @param {string} id - Part UUID. + * + * @returns {string} Available part label or unchanged reference. + */ + private partTitle(id: string): string { + return this.parts().find((part) => part.id === id)?.label ?? id; + } + + /** + * Method warehouseTitle + * @method warehouseTitle + * + * @description + * Resolves the authorized source label without an extra network request. + * + * @access private + * @since unreleased + * + * @param {string} id - Warehouse UUID. + * + * @returns {string} Available warehouse name or unchanged reference. + */ + private warehouseTitle(id: string): string { + return this.warehouses().find((warehouse) => warehouse.id === id)?.name ?? id; + } + //#endregion +} diff --git a/src/app/features/organization/features/inventory/ui/components/inventory-consumption-panel/testing/inventory-consumption-panel.component.spec.ts b/src/app/features/organization/features/inventory/ui/components/inventory-consumption-panel/testing/inventory-consumption-panel.component.spec.ts new file mode 100644 index 000000000..89c4a7993 --- /dev/null +++ b/src/app/features/organization/features/inventory/ui/components/inventory-consumption-panel/testing/inventory-consumption-panel.component.spec.ts @@ -0,0 +1,150 @@ +import { TestBed } from '@angular/core/testing'; +import type { + DeclareInventoryConsumptionInput, + InventoryConsumptionIntent, + InventoryConsumptionOutput, +} from '@features/organization/features/inventory/models'; +import { InventoryConsumptionPanel } from '../inventory-consumption-panel.component'; + +describe('InventoryConsumptionPanel', () => { + const draft = { partId: 'part', warehouseId: 'warehouse', quantity: '1.000000' }; + type Panel = { + declare: (input: typeof draft) => void; + retry: () => void; + retryIntent: (intent: InventoryConsumptionIntent) => void; + retainedCommand: () => DeclareInventoryConsumptionInput | null; + resetKey: () => number; + formLocked: () => boolean; + }; + beforeAll(() => { + globalThis.ResizeObserver ??= class { + observe(): void {} + unobserve(): void {} + disconnect(): void {} + } as unknown as typeof ResizeObserver; + }); + + async function setup() { + TestBed.configureTestingModule({ imports: [InventoryConsumptionPanel] }); + const fixture = TestBed.createComponent(InventoryConsumptionPanel); + fixture.componentRef.setInput('organizationId', 'org'); + fixture.componentRef.setInput('interventionId', 'intervention'); + await fixture.whenStable(); + const emitted = vi.fn(); + fixture.componentInstance.declared.subscribe(emitted); + return { fixture, panel: fixture.componentInstance as unknown as Panel, emitted }; + } + + it('generates one command and retains exact UUID, time and facts on failed retry', async () => { + const { fixture, panel, emitted } = await setup(); + panel.declare(draft); + panel.declare(draft); + expect(emitted).toHaveBeenCalledTimes(1); + const command = panel.retainedCommand(); + expect(command?.clientOperationId).toMatch(/^[0-9a-f-]{36}$/); + expect(command?.occurredAt).toMatch(/\.\d{3}Z$/); + expect(Object.isFrozen(command)).toBe(true); + expect(panel.formLocked()).toBe(true); + fixture.componentRef.setInput('error', 'Local storage unavailable'); + await fixture.whenStable(); + panel.retry(); + panel.retry(); + expect(emitted).toHaveBeenCalledTimes(2); + expect(emitted.mock.calls[1]?.[0]).toBe(command); + expect(panel.resetKey()).toBe(0); + }); + + it('resets only after a matching durable acknowledgment, never an unrelated server ID', async () => { + const { fixture, panel } = await setup(); + panel.declare(draft); + const command = panel.retainedCommand(); + fixture.componentRef.setInput('acceptedOperationId', 'unrelated-server-uuid'); + await fixture.whenStable(); + expect(panel.retainedCommand()).toBe(command); + fixture.componentRef.setInput('acceptedOperationId', command?.clientOperationId ?? 'missing'); + await fixture.whenStable(); + expect(panel.retainedCommand()).toBe(null); + expect(panel.resetKey()).toBe(1); + fixture.componentRef.setInput('acceptedOperationId', null); + await fixture.whenStable(); + expect(panel.resetKey()).toBe(1); + }); + + it('accepts matching persisted local intent and does not retry an old scope command', async () => { + const { fixture, panel, emitted } = await setup(); + panel.declare(draft); + const command = panel.retainedCommand(); + if (!command) throw new Error('Expected retained command'); + fixture.componentRef.setInput('error', 'Failed'); + fixture.componentRef.setInput('organizationId', 'new-org'); + await fixture.whenStable(); + panel.retry(); + expect(emitted).toHaveBeenCalledTimes(1); + fixture.componentRef.setInput('localIntents', [{ input: command, status: 'queued' }]); + await fixture.whenStable(); + expect(panel.retainedCommand()).toBe(null); + expect(panel.resetKey()).toBe(1); + }); + + it('separates queued facts, received-pending reconciliation, confirmed debit and late corrections', async () => { + const { fixture } = await setup(); + const input: DeclareInventoryConsumptionInput = { + ...draft, + clientOperationId: 'local', + interventionId: 'intervention', + occurredAt: '2026-10-06T10:00:00.000Z', + }; + const fact: InventoryConsumptionOutput = { + '@id': '/fact', + '@type': 'Consumption', + id: 'server', + ...draft, + interventionId: 'intervention', + actorId: 'actor', + occurredAt: input.occurredAt, + status: 'received_pending', + reason: 'insufficient_stock', + late: false, + replayed: false, + }; + fixture.componentRef.setInput('canDeclare', false); + fixture.componentRef.setInput('localIntents', [{ input, status: 'queued' }]); + fixture.componentRef.setInput('declarations', [ + fact, + { ...fact, id: 'confirmed', status: 'confirmed', reason: null, late: true }, + { ...fact, id: 'other', interventionId: 'other-intervention' }, + ]); + await fixture.whenStable(); + const text: string = fixture.nativeElement.textContent; + expect(text).toContain('Queued locally — awaiting synchronization'); + expect(text).toContain('Received — reconciliation needed'); + expect(text).toContain('Stock debit confirmed'); + expect(text).toContain('Recorded stock is insufficient'); + expect(text).toContain('published dossier remains unchanged'); + expect(text.match(/Received — reconciliation needed/g)).toHaveLength(1); + }); + + it('requests one replay of a failed durable intent, then permits retry after a new failed attempt', async () => { + const { fixture, panel, emitted } = await setup(); + const input: DeclareInventoryConsumptionInput = { + ...draft, + clientOperationId: 'stable-operation', + interventionId: 'intervention', + occurredAt: '2026-10-06T10:00:00.000Z', + }; + const intent: InventoryConsumptionIntent = { input, status: 'failed', error: 'Network error' }; + fixture.componentRef.setInput('localIntents', [intent]); + await fixture.whenStable(); + panel.retryIntent(intent); + panel.retryIntent(intent); + expect(emitted).toHaveBeenCalledTimes(1); + expect(emitted).toHaveBeenCalledWith(input); + fixture.componentRef.setInput('localIntents', [{ ...intent, status: 'sending' }]); + await fixture.whenStable(); + fixture.componentRef.setInput('localIntents', [{ ...intent, error: 'Still offline' }]); + await fixture.whenStable(); + panel.retryIntent(intent); + expect(emitted).toHaveBeenCalledTimes(2); + expect(emitted.mock.calls[1]?.[0]).toBe(input); + }); +}); diff --git a/src/app/features/organization/features/inventory/ui/components/inventory-part-picker/index.ts b/src/app/features/organization/features/inventory/ui/components/inventory-part-picker/index.ts new file mode 100644 index 000000000..3e0a01430 --- /dev/null +++ b/src/app/features/organization/features/inventory/ui/components/inventory-part-picker/index.ts @@ -0,0 +1 @@ +export { InventoryPartPicker } from './inventory-part-picker.component'; diff --git a/src/app/features/organization/features/inventory/ui/components/inventory-part-picker/inventory-part-picker.component.html b/src/app/features/organization/features/inventory/ui/components/inventory-part-picker/inventory-part-picker.component.html new file mode 100644 index 000000000..6aff53409 --- /dev/null +++ b/src/app/features/organization/features/inventory/ui/components/inventory-part-picker/inventory-part-picker.component.html @@ -0,0 +1,106 @@ + + + +
+ @for (item of store.referenceEntities(); track item.id) { + {{ labelOf(item.id) }} + {{ item.code }} + } + @if (store.listCallState().status === 'success' && store.referenceEntities().length === 0) { + No matching references + } +
+ @if (store.listCallState().status === 'pending') { +
+ Loading choices +
+ } +
+ + {{ store.query()?.page ?? 1 }} / {{ store.pageCount() }} + +
+
+
+@if (store.listCallState().error; as error) { +
+

Choices could not be loaded

+

{{ error.message }}

+ +
+} +@if (store.readCallState().error; as error) { +
+

+ Selected reference could not be loaded +

+

{{ error.message }}

+ +
+} diff --git a/src/app/features/organization/features/inventory/ui/components/inventory-part-picker/inventory-part-picker.component.ts b/src/app/features/organization/features/inventory/ui/components/inventory-part-picker/inventory-part-picker.component.ts new file mode 100644 index 000000000..e4579aae1 --- /dev/null +++ b/src/app/features/organization/features/inventory/ui/components/inventory-part-picker/inventory-part-picker.component.ts @@ -0,0 +1,301 @@ +import { isPlatformBrowser } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + PLATFORM_ID, + computed, + effect, + inject, + input, + model, + untracked, + type InputSignal, + type ModelSignal, + type Signal, +} from '@angular/core'; +import type { FormValueControl } from '@angular/forms/signals'; +import { + InventoryPickerStore, + type InventoryPickerStoreType, +} from '@features/organization/features/inventory/state/inventory-picker'; +import { HlmAlertImports } from '@shared/ui/alert'; +import { HlmButton } from '@shared/ui/button'; +import { HlmComboboxImports } from '@shared/ui/combobox'; +import { HlmSpinner } from '@shared/ui/spinner'; + +/** + * Class InventoryPartPicker + * @class InventoryPartPicker + * + * @description + * Browser-only part choice widget hydrates historical selections separately. + */ +@Component({ + selector: 'app-inventory-part-picker', + templateUrl: './inventory-part-picker.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [HlmButton, HlmSpinner, ...HlmComboboxImports, ...HlmAlertImports], + providers: [InventoryPickerStore], +}) +export class InventoryPartPicker implements FormValueControl { + //#region Properties + /** + * Property organizationId + * @readonly + * + * @description + * Organization authority for server choices and selected record hydration. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly organizationId: InputSignal = input(''); + + /** + * Property value + * @readonly + * + * @description + * Stable reference UUID exposed to Signal Forms. + * + * @access public + * @since unreleased + * + * @type {ModelSignal} + */ + public readonly value: ModelSignal = model(''); + + /** + * Property inputId + * @readonly + * + * @description + * Native label association supplied by the host form. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly inputId: InputSignal = input('inventory-part-picker'); + + /** + * Property disabled + * @readonly + * + * @description + * Locks selection without discarding its UUID. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly disabled: InputSignal = input(false); + + /** + * Property store + * @readonly + * + * @description + * Candidate pages and independently hydrated selection. + * + * @access protected + * @since unreleased + * + * @type {InventoryPickerStoreType} + */ + protected readonly store: InventoryPickerStoreType = inject(InventoryPickerStore); + + /** + * Property platformId + * @readonly + * + * @description + * Secondary authenticated reads never run during SSR. + * + * @access private + * @since unreleased + * + * @type {object} + */ + private readonly platformId: object = inject(PLATFORM_ID); + + /** + * Property placeholder + * @readonly + * + * @description + * Accessible selection prompt. + * + * @access protected + * @since unreleased + * + * @type {string} + */ + protected readonly placeholder: string = $localize`:@@inventory.picker.part.placeholder:Choose a part`; + + /** + * Property selectedLabel + * @readonly + * + * @description + * Displays an archived retained reference without offering it as a new choice. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly selectedLabel: Signal = computed(() => { + const item = this.store.readCallState().data; + if (item?.id !== this.value()) return this.value(); + return ( + ('label' in item ? item.label : item.name) + + (item.archived ? $localize`:@@inventory.picker.archived: (archived)` : '') + ); + }); + + /** + * Property labelOf + * @readonly + * + * @description + * Label callback preserves the selection across search pages. + * + * @access protected + * @since unreleased + * + * @type {(value: unknown) => string} + */ + protected readonly labelOf: (value: unknown) => string = (value) => { + if (typeof value !== 'string' || !value) return this.placeholder; + if (value === this.value()) return this.selectedLabel(); + const item = this.store.referenceEntities().find((candidate) => candidate.id === value); + return item ? ('label' in item ? item.label : item.name) : value; + }; + + /** + * Property remoteFilter + * @readonly + * + * @description + * Server search, rather than page-local filtering, owns matching. + * + * @access protected + * @since unreleased + * + * @type {() => boolean} + */ + protected readonly remoteFilter: () => boolean = () => true; + //#endregion + + //#region Constructor + /** + * Constructor + * @constructor + * + * @description + * Cancels obsolete organization reads and hydrates a selected UUID independently. + * + * @access public + * @since unreleased + */ + public constructor() { + effect(() => { + const organizationId = this.organizationId(); + const id = this.value(); + untracked(() => { + if (!isPlatformBrowser(this.platformId)) return; + if (this.store.query()?.organizationId !== organizationId) { + this.store.load(null); + if (organizationId) this.store.load({ organizationId, kind: 'part' }); + } + this.store.read(organizationId && id ? { organizationId, kind: 'part', id } : null); + }); + }); + } + //#endregion + + //#region Methods + /** + * Method search + * @method search + * + * @description + * Starts a new server search on page one. + * + * @access protected + * @since unreleased + * + * @param {string} search - Authorized directory search term. + * + * @returns {void} No return value. + */ + protected search(search: string): void { + if (!this.disabled() && isPlatformBrowser(this.platformId) && this.organizationId()) + this.store.load({ organizationId: this.organizationId(), kind: 'part', search, page: 1 }); + } + + /** + * Method page + * @method page + * + * @description + * Pages within the current server search. + * + * @access protected + * @since unreleased + * + * @param {number} page - Server page starting at one. + * + * @returns {void} No return value. + */ + protected page(page: number): void { + const query = this.store.query(); + if (!this.disabled() && query) this.store.load({ ...query, page }); + } + + /** + * Method retrySelection + * @method retrySelection + * + * @description + * Retries only the retained item read, preserving the candidate page. + * + * @access protected + * @since unreleased + * + * @returns {void} No return value. + */ + protected retrySelection(): void { + if (this.organizationId() && this.value() && isPlatformBrowser(this.platformId)) + this.store.read({ organizationId: this.organizationId(), kind: 'part', id: this.value() }); + } + + /** + * Method pick + * @method pick + * + * @description + * Refuses archived, unreturned and non-string new selections. + * + * @access protected + * @since unreleased + * + * @param {unknown} value - Candidate stable reference UUID. + * + * @returns {void} No return value. + */ + protected pick(value: unknown): void { + if (this.disabled() || typeof value !== 'string') return; + if ( + !value || + this.store.referenceEntities().some((item) => item.id === value && !item.archived) + ) + this.value.set(value); + } + //#endregion +} diff --git a/src/app/features/organization/features/inventory/ui/components/inventory-part-picker/testing/inventory-part-picker.component.spec.ts b/src/app/features/organization/features/inventory/ui/components/inventory-part-picker/testing/inventory-part-picker.component.spec.ts new file mode 100644 index 000000000..606e20969 --- /dev/null +++ b/src/app/features/organization/features/inventory/ui/components/inventory-part-picker/testing/inventory-part-picker.component.spec.ts @@ -0,0 +1,86 @@ +import { PLATFORM_ID } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { of } from 'rxjs'; +import { InventoryService } from '@features/organization/features/inventory/data-access'; +import { InventoryPartPicker } from '../inventory-part-picker.component'; + +describe('InventoryPartPicker', () => { + beforeAll(() => { + globalThis.ResizeObserver ??= class { + observe(): void {} + unobserve(): void {} + disconnect(): void {} + } as unknown as typeof ResizeObserver; + }); + const active = { + '@id': '/part', + '@type': 'InventoryPart', + id: 'part-id', + code: 'SEAL', + label: 'Seal', + unit: 'piece', + kind: 'part', + archived: false, + }; + const archived = { ...active, id: 'historic-id', archived: true }; + + it('performs no authenticated secondary reads during SSR', async () => { + const service = { + listParts: vi.fn(), + listWarehouses: vi.fn(), + readPart: vi.fn(), + readWarehouse: vi.fn(), + }; + TestBed.configureTestingModule({ + imports: [InventoryPartPicker], + providers: [ + { provide: PLATFORM_ID, useValue: 'server' }, + { provide: InventoryService, useValue: service }, + ], + }); + const fixture = TestBed.createComponent(InventoryPartPicker); + fixture.componentRef.setInput('organizationId', 'org'); + fixture.componentRef.setInput('value', archived.id); + await fixture.whenStable(); + expect(service.listParts).not.toHaveBeenCalled(); + expect(service.readPart).not.toHaveBeenCalled(); + }); + + it('retains selected archived UUID while searching active pages and refuses unreturned assignments', async () => { + const service = { + listParts: vi.fn().mockReturnValue(of({ member: [active, archived], totalItems: 1 })), + readPart: vi.fn().mockReturnValue(of(archived)), + }; + TestBed.configureTestingModule({ + imports: [InventoryPartPicker], + providers: [{ provide: InventoryService, useValue: service }], + }); + const fixture = TestBed.createComponent(InventoryPartPicker); + fixture.componentRef.setInput('organizationId', 'org'); + fixture.componentRef.setInput('value', archived.id); + await fixture.whenStable(); + const picker = fixture.componentInstance as unknown as { + selectedLabel: () => string; + pick: (value: unknown) => void; + search: (search: string) => void; + page: (page: number) => void; + }; + expect(picker.selectedLabel()).toBe('Seal (archived)'); + picker.pick('unknown-id'); + expect(fixture.componentInstance.value()).toBe(archived.id); + picker.pick(active.id); + expect(fixture.componentInstance.value()).toBe(active.id); + picker.search('searched'); + picker.page(2); + expect(service.listParts).toHaveBeenLastCalledWith('org', { + page: 2, + itemsPerPage: 50, + search: 'searched', + params: { archived: false }, + }); + fixture.componentRef.setInput('disabled', true); + await fixture.whenStable(); + picker.pick(''); + expect(fixture.componentInstance.value()).toBe(active.id); + }); +}); diff --git a/src/app/features/organization/features/inventory/ui/components/inventory-warehouse-picker/index.ts b/src/app/features/organization/features/inventory/ui/components/inventory-warehouse-picker/index.ts new file mode 100644 index 000000000..1bf237acc --- /dev/null +++ b/src/app/features/organization/features/inventory/ui/components/inventory-warehouse-picker/index.ts @@ -0,0 +1 @@ +export { InventoryWarehousePicker } from './inventory-warehouse-picker.component'; diff --git a/src/app/features/organization/features/inventory/ui/components/inventory-warehouse-picker/inventory-warehouse-picker.component.html b/src/app/features/organization/features/inventory/ui/components/inventory-warehouse-picker/inventory-warehouse-picker.component.html new file mode 100644 index 000000000..6aff53409 --- /dev/null +++ b/src/app/features/organization/features/inventory/ui/components/inventory-warehouse-picker/inventory-warehouse-picker.component.html @@ -0,0 +1,106 @@ + + + +
+ @for (item of store.referenceEntities(); track item.id) { + {{ labelOf(item.id) }} + {{ item.code }} + } + @if (store.listCallState().status === 'success' && store.referenceEntities().length === 0) { + No matching references + } +
+ @if (store.listCallState().status === 'pending') { +
+ Loading choices +
+ } +
+ + {{ store.query()?.page ?? 1 }} / {{ store.pageCount() }} + +
+
+
+@if (store.listCallState().error; as error) { +
+

Choices could not be loaded

+

{{ error.message }}

+ +
+} +@if (store.readCallState().error; as error) { +
+

+ Selected reference could not be loaded +

+

{{ error.message }}

+ +
+} diff --git a/src/app/features/organization/features/inventory/ui/components/inventory-warehouse-picker/inventory-warehouse-picker.component.ts b/src/app/features/organization/features/inventory/ui/components/inventory-warehouse-picker/inventory-warehouse-picker.component.ts new file mode 100644 index 000000000..451ff2c2f --- /dev/null +++ b/src/app/features/organization/features/inventory/ui/components/inventory-warehouse-picker/inventory-warehouse-picker.component.ts @@ -0,0 +1,310 @@ +import { isPlatformBrowser } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + PLATFORM_ID, + computed, + effect, + inject, + input, + model, + untracked, + type InputSignal, + type ModelSignal, + type Signal, +} from '@angular/core'; +import type { FormValueControl } from '@angular/forms/signals'; +import { + InventoryPickerStore, + type InventoryPickerStoreType, +} from '@features/organization/features/inventory/state/inventory-picker'; +import { HlmAlertImports } from '@shared/ui/alert'; +import { HlmButton } from '@shared/ui/button'; +import { HlmComboboxImports } from '@shared/ui/combobox'; +import { HlmSpinner } from '@shared/ui/spinner'; + +/** + * Class InventoryWarehousePicker + * @class InventoryWarehousePicker + * + * @description + * Browser-only warehouse choice widget hydrates historical selections separately. + */ +@Component({ + selector: 'app-inventory-warehouse-picker', + templateUrl: './inventory-warehouse-picker.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [HlmButton, HlmSpinner, ...HlmComboboxImports, ...HlmAlertImports], + providers: [InventoryPickerStore], +}) +export class InventoryWarehousePicker implements FormValueControl { + //#region Properties + /** + * Property organizationId + * @readonly + * + * @description + * Organization authority for server choices and selected record hydration. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly organizationId: InputSignal = input(''); + + /** + * Property value + * @readonly + * + * @description + * Stable reference UUID exposed to Signal Forms. + * + * @access public + * @since unreleased + * + * @type {ModelSignal} + */ + public readonly value: ModelSignal = model(''); + + /** + * Property inputId + * @readonly + * + * @description + * Native label association supplied by the host form. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly inputId: InputSignal = input('inventory-warehouse-picker'); + + /** + * Property disabled + * @readonly + * + * @description + * Locks selection without discarding its UUID. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly disabled: InputSignal = input(false); + + /** + * Property store + * @readonly + * + * @description + * Candidate pages and independently hydrated selection. + * + * @access protected + * @since unreleased + * + * @type {InventoryPickerStoreType} + */ + protected readonly store: InventoryPickerStoreType = inject(InventoryPickerStore); + + /** + * Property platformId + * @readonly + * + * @description + * Secondary authenticated reads never run during SSR. + * + * @access private + * @since unreleased + * + * @type {object} + */ + private readonly platformId: object = inject(PLATFORM_ID); + + /** + * Property placeholder + * @readonly + * + * @description + * Accessible selection prompt. + * + * @access protected + * @since unreleased + * + * @type {string} + */ + protected readonly placeholder: string = $localize`:@@inventory.picker.warehouse.placeholder:Choose a warehouse`; + + /** + * Property selectedLabel + * @readonly + * + * @description + * Displays an archived retained reference without offering it as a new choice. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly selectedLabel: Signal = computed(() => { + const item = this.store.readCallState().data; + if (item?.id !== this.value()) return this.value(); + return ( + ('label' in item ? item.label : item.name) + + (item.archived ? $localize`:@@inventory.picker.archived: (archived)` : '') + ); + }); + + /** + * Property labelOf + * @readonly + * + * @description + * Label callback preserves the selection across search pages. + * + * @access protected + * @since unreleased + * + * @type {(value: unknown) => string} + */ + protected readonly labelOf: (value: unknown) => string = (value) => { + if (typeof value !== 'string' || !value) return this.placeholder; + if (value === this.value()) return this.selectedLabel(); + const item = this.store.referenceEntities().find((candidate) => candidate.id === value); + return item ? ('label' in item ? item.label : item.name) : value; + }; + + /** + * Property remoteFilter + * @readonly + * + * @description + * Server search, rather than page-local filtering, owns matching. + * + * @access protected + * @since unreleased + * + * @type {() => boolean} + */ + protected readonly remoteFilter: () => boolean = () => true; + //#endregion + + //#region Constructor + /** + * Constructor + * @constructor + * + * @description + * Cancels obsolete organization reads and hydrates a selected UUID independently. + * + * @access public + * @since unreleased + */ + public constructor() { + effect(() => { + const organizationId = this.organizationId(); + const id = this.value(); + untracked(() => { + if (!isPlatformBrowser(this.platformId)) return; + if (this.store.query()?.organizationId !== organizationId) { + this.store.load(null); + if (organizationId) this.store.load({ organizationId, kind: 'warehouse' }); + } + this.store.read(organizationId && id ? { organizationId, kind: 'warehouse', id } : null); + }); + }); + } + //#endregion + + //#region Methods + /** + * Method search + * @method search + * + * @description + * Starts a new server search on page one. + * + * @access protected + * @since unreleased + * + * @param {string} search - Authorized directory search term. + * + * @returns {void} No return value. + */ + protected search(search: string): void { + if (!this.disabled() && isPlatformBrowser(this.platformId) && this.organizationId()) + this.store.load({ + organizationId: this.organizationId(), + kind: 'warehouse', + search, + page: 1, + }); + } + + /** + * Method page + * @method page + * + * @description + * Pages within the current server search. + * + * @access protected + * @since unreleased + * + * @param {number} page - Server page starting at one. + * + * @returns {void} No return value. + */ + protected page(page: number): void { + const query = this.store.query(); + if (!this.disabled() && query) this.store.load({ ...query, page }); + } + + /** + * Method retrySelection + * @method retrySelection + * + * @description + * Retries only the retained item read, preserving the candidate page. + * + * @access protected + * @since unreleased + * + * @returns {void} No return value. + */ + protected retrySelection(): void { + if (this.organizationId() && this.value() && isPlatformBrowser(this.platformId)) + this.store.read({ + organizationId: this.organizationId(), + kind: 'warehouse', + id: this.value(), + }); + } + + /** + * Method pick + * @method pick + * + * @description + * Refuses archived, unreturned and non-string new selections. + * + * @access protected + * @since unreleased + * + * @param {unknown} value - Candidate stable reference UUID. + * + * @returns {void} No return value. + */ + protected pick(value: unknown): void { + if (this.disabled() || typeof value !== 'string') return; + if ( + !value || + this.store.referenceEntities().some((item) => item.id === value && !item.archived) + ) + this.value.set(value); + } + //#endregion +} diff --git a/src/app/features/organization/features/inventory/ui/components/inventory-warehouse-picker/testing/inventory-warehouse-picker.component.spec.ts b/src/app/features/organization/features/inventory/ui/components/inventory-warehouse-picker/testing/inventory-warehouse-picker.component.spec.ts new file mode 100644 index 000000000..964baa2a5 --- /dev/null +++ b/src/app/features/organization/features/inventory/ui/components/inventory-warehouse-picker/testing/inventory-warehouse-picker.component.spec.ts @@ -0,0 +1,84 @@ +import { PLATFORM_ID } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { of } from 'rxjs'; +import { InventoryService } from '@features/organization/features/inventory/data-access'; +import { InventoryWarehousePicker } from '../inventory-warehouse-picker.component'; + +describe('InventoryWarehousePicker', () => { + beforeAll(() => { + globalThis.ResizeObserver ??= class { + observe(): void {} + unobserve(): void {} + disconnect(): void {} + } as unknown as typeof ResizeObserver; + }); + const active = { + '@id': '/warehouse', + '@type': 'InventoryWarehouse', + id: 'warehouse-id', + code: 'VAN', + name: 'Van', + archived: false, + }; + const archived = { ...active, id: 'historic-id', archived: true }; + + it('performs no authenticated secondary reads during SSR', async () => { + const service = { + listParts: vi.fn(), + listWarehouses: vi.fn(), + readPart: vi.fn(), + readWarehouse: vi.fn(), + }; + TestBed.configureTestingModule({ + imports: [InventoryWarehousePicker], + providers: [ + { provide: PLATFORM_ID, useValue: 'server' }, + { provide: InventoryService, useValue: service }, + ], + }); + const fixture = TestBed.createComponent(InventoryWarehousePicker); + fixture.componentRef.setInput('organizationId', 'org'); + fixture.componentRef.setInput('value', archived.id); + await fixture.whenStable(); + expect(service.listWarehouses).not.toHaveBeenCalled(); + expect(service.readWarehouse).not.toHaveBeenCalled(); + }); + + it('retains selected archived UUID while searching active pages and refuses unreturned assignments', async () => { + const service = { + listWarehouses: vi.fn().mockReturnValue(of({ member: [active, archived], totalItems: 1 })), + readWarehouse: vi.fn().mockReturnValue(of(archived)), + }; + TestBed.configureTestingModule({ + imports: [InventoryWarehousePicker], + providers: [{ provide: InventoryService, useValue: service }], + }); + const fixture = TestBed.createComponent(InventoryWarehousePicker); + fixture.componentRef.setInput('organizationId', 'org'); + fixture.componentRef.setInput('value', archived.id); + await fixture.whenStable(); + const picker = fixture.componentInstance as unknown as { + selectedLabel: () => string; + pick: (value: unknown) => void; + search: (search: string) => void; + page: (page: number) => void; + }; + expect(picker.selectedLabel()).toBe('Van (archived)'); + picker.pick('unknown-id'); + expect(fixture.componentInstance.value()).toBe(archived.id); + picker.pick(active.id); + expect(fixture.componentInstance.value()).toBe(active.id); + picker.search('searched'); + picker.page(2); + expect(service.listWarehouses).toHaveBeenLastCalledWith('org', { + page: 2, + itemsPerPage: 50, + search: 'searched', + params: { archived: false }, + }); + fixture.componentRef.setInput('disabled', true); + await fixture.whenStable(); + picker.pick(''); + expect(fixture.componentInstance.value()).toBe(active.id); + }); +}); diff --git a/src/app/features/organization/features/inventory/ui/dataviews/inventory-dataview/index.ts b/src/app/features/organization/features/inventory/ui/dataviews/inventory-dataview/index.ts new file mode 100644 index 000000000..d5dca532f --- /dev/null +++ b/src/app/features/organization/features/inventory/ui/dataviews/inventory-dataview/index.ts @@ -0,0 +1,2 @@ +export { InventoryDataview } from './inventory-dataview.component'; +export type { InventoryRow, InventoryRowAction } from './inventory-row.interface'; diff --git a/src/app/features/organization/features/inventory/ui/dataviews/inventory-dataview/inventory-dataview.component.html b/src/app/features/organization/features/inventory/ui/dataviews/inventory-dataview/inventory-dataview.component.html new file mode 100644 index 000000000..2415c1524 --- /dev/null +++ b/src/app/features/organization/features/inventory/ui/dataviews/inventory-dataview/inventory-dataview.component.html @@ -0,0 +1,161 @@ +@if (loading() && rows().length === 0) { +
+ @for (item of [1, 2, 3]; track item) { +
+ } +
+} + + @for (row of rows(); track row.record.id) { +
+
+
+ {{ row.title }} + @if (row.status) { + {{ row.status }} + } + @if (row.late) { + Received after publication + } +
+

{{ row.detail }}

+ @if (row.quantity) { +

+ Quantity: {{ row.quantity }} {{ row.unit }} +

+ } + @if (row.date) { +

+ {{ row.date | appOrgDate: 'datetime' : regionalFormatting() }} +

+ } + @if (row.reason) { +

{{ row.reason }}

+ } + @if (row.interventionId) { + Open intervention + } +
+
+ @if (row.canEdit) { + + } + @if (row.canArchive) { + + } + @if (row.canReconcile) { + + } + @if (row.canReturn) { + + } +
+
+ } +
+@if (loaded() && rows().length === 0) { +

No stock records in this view

+

+ Change your filters or add a reference. Archived references remain readable in the history. +

+} +@if (loaded() && pageCount() > 1) { + +} diff --git a/src/app/features/organization/features/inventory/ui/dataviews/inventory-dataview/inventory-dataview.component.ts b/src/app/features/organization/features/inventory/ui/dataviews/inventory-dataview/inventory-dataview.component.ts new file mode 100644 index 000000000..9dc3de1db --- /dev/null +++ b/src/app/features/organization/features/inventory/ui/dataviews/inventory-dataview/inventory-dataview.component.ts @@ -0,0 +1,168 @@ +import { ChangeDetectionStrategy, Component, input, output } from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { + DEFAULT_REGIONAL_FORMAT_SETTINGS, + OrgDatePipe, + type RegionalFormatSettings, +} from '@shared/regional-format'; +import { HlmBadge } from '@shared/ui/badge'; +import { HlmButton } from '@shared/ui/button'; +import { HlmEmptyImports } from '@shared/ui/empty'; +import { HlmItemImports } from '@shared/ui/item'; +import { HlmSkeleton } from '@shared/ui/skeleton'; +import type { InventoryRow, InventoryRowAction } from './inventory-row.interface'; + +/** + * Class InventoryDataview + * + * @description + * Responsive stock facts and paginated history emit intent without feature-store access. + */ +@Component({ + selector: 'app-inventory-dataview', + templateUrl: './inventory-dataview.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + OrgDatePipe, + RouterLink, + HlmBadge, + HlmButton, + HlmSkeleton, + ...HlmItemImports, + ...HlmEmptyImports, + ], +}) +export class InventoryDataview { + /** + * Property organizationId + * @readonly + * + * @description + * Scope of linked work dossiers. + * + * @access public + * @since unreleased + * + * @type {import('@angular/core').InputSignal} + */ + public readonly organizationId = input.required(); + /** + * Property regionalFormatting + * @readonly + * + * @description + * Organization date pattern and timezone. + * + * @access public + * @since unreleased + * + * @type {import('@angular/core').InputSignal} + */ + public readonly regionalFormatting = input( + DEFAULT_REGIONAL_FORMAT_SETTINGS, + ); + /** + * Property rows + * @readonly + * + * @description + * Prepared quantity-only records. + * + * @access public + * @since unreleased + * + * @type {import('@angular/core').InputSignal} + */ + public readonly rows = input([]); + /** + * Property loading + * @readonly + * + * @description + * Initial or refreshed query progress. + * + * @access public + * @since unreleased + * + * @type {import('@angular/core').InputSignal} + */ + public readonly loading = input(false); + /** + * Property loaded + * @readonly + * + * @description + * Empty state requires a successful server read. + * + * @access public + * @since unreleased + * + * @type {import('@angular/core').InputSignal} + */ + public readonly loaded = input(false); + /** + * Property disabled + * @readonly + * + * @description + * Offline state or accepted command blocks new row actions. + * + * @access public + * @since unreleased + * + * @type {import('@angular/core').InputSignal} + */ + public readonly disabled = input(false); + /** + * Property page + * @readonly + * + * @description + * Current server page. + * + * @access public + * @since unreleased + * + * @type {import('@angular/core').InputSignal} + */ + public readonly page = input(1); + /** + * Property pageCount + * @readonly + * + * @description + * Exact server page count. + * + * @access public + * @since unreleased + * + * @type {import('@angular/core').InputSignal} + */ + public readonly pageCount = input(1); + /** + * Property action + * @readonly + * + * @description + * Explicit host-owned row command. + * + * @access public + * @since unreleased + * + * @type {import('@angular/core').OutputEmitterRef} + */ + public readonly action = output(); + /** + * Property pageChanged + * @readonly + * + * @description + * Requested next server page. + * + * @access public + * @since unreleased + * + * @type {import('@angular/core').OutputEmitterRef} + */ + public readonly pageChanged = output(); +} diff --git a/src/app/features/organization/features/inventory/ui/dataviews/inventory-dataview/inventory-row.interface.ts b/src/app/features/organization/features/inventory/ui/dataviews/inventory-dataview/inventory-row.interface.ts new file mode 100644 index 000000000..93229009e --- /dev/null +++ b/src/app/features/organization/features/inventory/ui/dataviews/inventory-dataview/inventory-row.interface.ts @@ -0,0 +1,256 @@ +import type { InventoryRecord } from '@features/organization/features/inventory/state/inventory'; + +/** + * Interface InventoryRow + * @interface InventoryRow + * + * @description + * Quantity-only row projection, independent from API and page state. + */ +export interface InventoryRow { + /** + * Property archived + * @readonly + * + * @description + * Retained reference is archived. + * + * @access public + * @since unreleased + * + * @type {boolean | undefined} + */ + readonly archived?: boolean; + /** + * Property record + * @readonly + * + * @description + * Canonical original fact for explicit row actions. + * + * @access public + * @since unreleased + * + * @type {InventoryRecord} + */ + readonly record: InventoryRecord; + /** + * Property title + * @readonly + * + * @description + * Human name. + * + * @access public + * @since unreleased + * + * @type {string} + */ + readonly title: string; + /** + * Property detail + * @readonly + * + * @description + * Stable reference code or warehouse. + * + * @access public + * @since unreleased + * + * @type {string} + */ + readonly detail: string; + /** + * Property quantity + * @readonly + * + * @description + * Exact transport decimal string, never floating-point formatted. + * + * @access public + * @since unreleased + * + * @type {string | undefined} + */ + readonly quantity?: string; + /** + * Property unit + * @readonly + * + * @description + * Declared stock unit. + * + * @access public + * @since unreleased + * + * @type {string | undefined} + */ + readonly unit?: string; + /** + * Property status + * @readonly + * + * @description + * Human lifecycle or declaration state. + * + * @access public + * @since unreleased + * + * @type {string | undefined} + */ + readonly status?: string; + /** + * Property pending + * @readonly + * + * @description + * Server received declaration still requiring reconciliation. + * + * @access public + * @since unreleased + * + * @type {boolean | undefined} + */ + readonly pending?: boolean; + /** + * Property date + * @readonly + * + * @description + * Original physical occurrence timestamp. + * + * @access public + * @since unreleased + * + * @type {string | undefined} + */ + readonly date?: string; + /** + * Property reason + * @readonly + * + * @description + * Motivation or explicit reconciliation reason. + * + * @access public + * @since unreleased + * + * @type {string | undefined} + */ + readonly reason?: string; + /** + * Property late + * @readonly + * + * @description + * Declaration received after the dossier was published. + * + * @access public + * @since unreleased + * + * @type {boolean | undefined} + */ + readonly late?: boolean; + /** + * Property interventionId + * @readonly + * + * @description + * Linked work dossier. + * + * @access public + * @since unreleased + * + * @type {string | undefined} + */ + readonly interventionId?: string; + /** + * Property canEdit + * @readonly + * + * @description + * Caller-authorized reference mutation. + * + * @access public + * @since unreleased + * + * @type {boolean | undefined} + */ + readonly canEdit?: boolean; + /** + * Property canArchive + * @readonly + * + * @description + * Caller-authorized archive lifecycle. + * + * @access public + * @since unreleased + * + * @type {boolean | undefined} + */ + readonly canArchive?: boolean; + /** + * Property canReconcile + * @readonly + * + * @description + * Caller-authorized whole declaration reconciliation. + * + * @access public + * @since unreleased + * + * @type {boolean | undefined} + */ + readonly canReconcile?: boolean; + /** + * Property canReturn + * @readonly + * + * @description + * Caller-authorized physical return from a confirmed consumption. + * + * @access public + * @since unreleased + * + * @type {boolean | undefined} + */ + readonly canReturn?: boolean; +} + +/** + * Type InventoryRowAction + * + * @description + * Stateless dataview asks its host to execute a specific intent. + * + * @type InventoryRowAction + */ +export type InventoryRowAction = { + /** + * Property kind + * @readonly + * + * @description + * Requested explicit row action. + * + * @access public + * @since unreleased + * + * @type {'edit' | 'archive' | 'reconcile' | 'return'} + */ + readonly kind: 'edit' | 'archive' | 'reconcile' | 'return'; + /** + * Property record + * @readonly + * + * @description + * Canonical original record for action authority. + * + * @access public + * @since unreleased + * + * @type {InventoryRecord} + */ + readonly record: InventoryRecord; +}; diff --git a/src/app/features/organization/features/inventory/ui/forms/index.ts b/src/app/features/organization/features/inventory/ui/forms/index.ts new file mode 100644 index 000000000..709ec0e72 --- /dev/null +++ b/src/app/features/organization/features/inventory/ui/forms/index.ts @@ -0,0 +1,6 @@ +export { InventoryConsumptionForm } from './inventory-consumption-form'; +export { + InventoryReferenceForm, + type InventoryReferenceSubmission, +} from './inventory-reference-form'; +export { InventoryMovementForm, type InventoryMovementDraft } from './inventory-movement-form'; diff --git a/src/app/features/organization/features/inventory/ui/forms/inventory-consumption-form/index.ts b/src/app/features/organization/features/inventory/ui/forms/inventory-consumption-form/index.ts new file mode 100644 index 000000000..12a7a9767 --- /dev/null +++ b/src/app/features/organization/features/inventory/ui/forms/inventory-consumption-form/index.ts @@ -0,0 +1 @@ +export { InventoryConsumptionForm } from './inventory-consumption-form.component'; diff --git a/src/app/features/organization/features/inventory/ui/forms/inventory-consumption-form/inventory-consumption-form.component.html b/src/app/features/organization/features/inventory/ui/forms/inventory-consumption-form/inventory-consumption-form.component.html new file mode 100644 index 000000000..26c530231 --- /dev/null +++ b/src/app/features/organization/features/inventory/ui/forms/inventory-consumption-form/inventory-consumption-form.component.html @@ -0,0 +1,108 @@ +
+
+ Declare parts used +

+ Record the physical quantity used. Stock is confirmed after synchronization. +

+
+
+ + + + +
+ @for (part of activeParts(); track part.id) { + {{ part.label }} · {{ part.code }} · {{ part.unit }} + } + No matching references +
+
+
+ @if (consumptionForm.partId().touched()) { + @for (error of consumptionForm.partId().errors(); track error) { + {{ error.message }} + } + } +
+
+ + + + +
+ @for (warehouse of activeWarehouses(); track warehouse.id) { + {{ warehouse.name }} · {{ warehouse.code }} + } + No matching references +
+
+
+ @if (consumptionForm.warehouseId().touched()) { + @for (error of consumptionForm.warehouseId().errors(); track error) { + {{ error.message }} + } + } +
+
+ + + @if (consumptionForm.quantity().touched()) { + @for (error of consumptionForm.quantity().errors(); track error) { + {{ error.message }} + } + } +
+
+
+ @if (error(); as message) { +
+

Declaration could not be queued

+

{{ message }}

+
+ } +
+ +
+
diff --git a/src/app/features/organization/features/inventory/ui/forms/inventory-consumption-form/inventory-consumption-form.component.ts b/src/app/features/organization/features/inventory/ui/forms/inventory-consumption-form/inventory-consumption-form.component.ts new file mode 100644 index 000000000..8979f1315 --- /dev/null +++ b/src/app/features/organization/features/inventory/ui/forms/inventory-consumption-form/inventory-consumption-form.component.ts @@ -0,0 +1,361 @@ +import { + ChangeDetectionStrategy, + Component, + effect, + input, + output, + signal, + untracked, + type InputSignal, + type OutputEmitterRef, + type WritableSignal, + type Signal, + computed, +} from '@angular/core'; +import { + disabled, + form, + FormField, + required, + validate, + type FieldTree, +} from '@angular/forms/signals'; +import type { + DeclareInventoryConsumptionInput, + InventoryPartOutput, + InventoryWarehouseOutput, +} from '@features/organization/features/inventory/models'; +import { normalizeInventoryQuantity } from '@features/organization/features/inventory/utils'; +import { HlmAlertImports } from '@shared/ui/alert'; +import { HlmButton } from '@shared/ui/button'; +import { HlmComboboxImports } from '@shared/ui/combobox'; +import { HlmFieldImports } from '@shared/ui/field'; +import { HlmInput } from '@shared/ui/input'; +import { HlmSpinner } from '@shared/ui/spinner'; + +/** + * Class InventoryConsumptionForm + * @class InventoryConsumptionForm + * + * @description + * Exact-decimal consumption presenter using authorized cached choices without transport. + */ +@Component({ + selector: 'app-inventory-consumption-form', + templateUrl: './inventory-consumption-form.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + FormField, + HlmButton, + HlmInput, + HlmSpinner, + ...HlmComboboxImports, + ...HlmFieldImports, + ...HlmAlertImports, + ], +}) +export class InventoryConsumptionForm { + //#region Properties + /** + * Property parts + * @readonly + * + * @description + * Authorized cached parts; archived references cannot be newly consumed. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly parts: InputSignal = input< + readonly InventoryPartOutput[] + >([]); + + /** + * Property warehouses + * @readonly + * + * @description + * Authorized cached warehouses usable without a new online lookup. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly warehouses: InputSignal = input< + readonly InventoryWarehouseOutput[] + >([]); + + /** + * Property pending + * @readonly + * + * @description + * Submission progress provided by the owning workflow. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly pending: InputSignal = input(false); + + /** + * Property locked + * @readonly + * + * @description + * An uncertain retained declaration prevents changing its immutable command fields. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly locked: InputSignal = input(false); + + /** + * Property error + * @readonly + * + * @description + * Submission error never replaces the entered draft. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly error: InputSignal = input(null); + + /** + * Property resetKey + * @readonly + * + * @description + * Changes only after the owner confirms durable acceptance of the submitted declaration. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly resetKey: InputSignal = input(0); + + /** + * Property inputPrefix + * @readonly + * + * @description + * Unique native label prefix when several forms are mounted. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly inputPrefix: InputSignal = input('inventory-consumption'); + + /** + * Property submitted + * @readonly + * + * @description + * Validated references and decimal string; the owner adds command identity and context. + * + * @access public + * @since unreleased + * + * @type {OutputEmitterRef< + * Pick + * >} + */ + public readonly submitted: OutputEmitterRef< + Pick + > = output>(); + + /** + * Property draft + * @readonly + * + * @description + * Quantity remains a string through validation and submission. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal< + * Pick + * >} + */ + protected readonly draft: WritableSignal< + Pick + > = signal({ partId: '', warehouseId: '', quantity: '' }); + + /** + * Property activeParts + * @readonly + * + * @description + * Only active cached parts are offered for new declarations. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly activeParts: Signal = computed(() => + this.parts().filter((part) => !part.archived), + ); + + /** + * Property activeWarehouses + * @readonly + * + * @description + * Only active cached warehouses are offered for new declarations. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly activeWarehouses: Signal = computed(() => + this.warehouses().filter((warehouse) => !warehouse.archived), + ); + + /** + * Property partLabelOf + * @readonly + * + * @description + * Keeps an entered part recognizable when its authorized projection refreshes. + * + * @access protected + * @since unreleased + * + * @type {(value: unknown) => string} + */ + protected readonly partLabelOf: (value: unknown) => string = (value) => + typeof value === 'string' + ? (this.parts().find((part) => part.id === value)?.label ?? value) + : ''; + + /** + * Property warehouseLabelOf + * @readonly + * + * @description + * Keeps an entered warehouse recognizable after reference refreshes. + * + * @access protected + * @since unreleased + * + * @type {(value: unknown) => string} + */ + protected readonly warehouseLabelOf: (value: unknown) => string = (value) => + typeof value === 'string' + ? (this.warehouses().find((warehouse) => warehouse.id === value)?.name ?? value) + : ''; + + /** + * Property consumptionForm + * @readonly + * + * @description + * Validates actual cached authorization and positive decimals without floating-point coercion. + * + * @access protected + * @since unreleased + * + * @type {FieldTree< + * Pick + * >} + */ + protected readonly consumptionForm: FieldTree< + Pick + > = form(this.draft, (path) => { + disabled(path, () => this.pending() || this.locked()); + required(path.partId, { + message: $localize`:@@inventory.consumption.partRequired:Choose an active part.`, + }); + validate(path.partId, ({ value }) => + this.activeParts().some((part) => part.id === value()) + ? null + : { + kind: 'reference', + message: $localize`:@@inventory.consumption.partRequired:Choose an active part.`, + }, + ); + required(path.warehouseId, { + message: $localize`:@@inventory.consumption.warehouseRequired:Choose an active warehouse.`, + }); + validate(path.warehouseId, ({ value }) => + this.activeWarehouses().some((warehouse) => warehouse.id === value()) + ? null + : { + kind: 'reference', + message: $localize`:@@inventory.consumption.warehouseRequired:Choose an active warehouse.`, + }, + ); + required(path.quantity, { + message: $localize`:@@inventory.consumption.quantityRequired:Enter the quantity used.`, + }); + validate(path.quantity, ({ value }) => + normalizeInventoryQuantity(value()) + ? null + : { + kind: 'quantity', + message: $localize`:@@inventory.consumption.quantityInvalid:Enter a positive decimal with up to 18 whole digits and 6 decimal places, using a dot.`, + }, + ); + }); + //#endregion + + //#region Constructor + /** + * Constructor + * @constructor + * + * @description + * Only explicit durable acceptance resets the entered consumption draft. + * + * @access public + * @since unreleased + */ + public constructor() { + effect(() => { + this.resetKey(); + untracked(() => this.consumptionForm().reset({ partId: '', warehouseId: '', quantity: '' })); + }); + } + //#endregion + + //#region Methods + /** + * Method submit + * @method submit + * + * @description + * Reveals errors and emits the exact string only when the draft is valid and unlocked. + * + * @access protected + * @since unreleased + * + * @param {Event} event - Native form submit event. + * + * @returns {void} No return value. + */ + protected submit(event: Event): void { + event.preventDefault(); + this.consumptionForm().markAsTouched(); + if (this.pending() || this.locked() || this.consumptionForm().invalid()) return; + const draft = this.draft(); + const quantity = normalizeInventoryQuantity(draft.quantity); + if (!quantity) return; + this.submitted.emit({ ...draft, quantity }); + } + //#endregion +} diff --git a/src/app/features/organization/features/inventory/ui/forms/inventory-consumption-form/testing/inventory-consumption-form.component.spec.ts b/src/app/features/organization/features/inventory/ui/forms/inventory-consumption-form/testing/inventory-consumption-form.component.spec.ts new file mode 100644 index 000000000..d3b7fddf5 --- /dev/null +++ b/src/app/features/organization/features/inventory/ui/forms/inventory-consumption-form/testing/inventory-consumption-form.component.spec.ts @@ -0,0 +1,112 @@ +import { TestBed } from '@angular/core/testing'; +import type { + DeclareInventoryConsumptionInput, + InventoryPartOutput, + InventoryWarehouseOutput, +} from '@features/organization/features/inventory/models'; +import { InventoryConsumptionForm } from '../inventory-consumption-form.component'; + +describe('InventoryConsumptionForm', () => { + const part: InventoryPartOutput = { + '@id': '/part', + '@type': 'InventoryPart', + id: 'part-id', + code: 'SEAL', + label: 'Seal', + unit: 'piece', + kind: 'part', + archived: false, + }; + const warehouse: InventoryWarehouseOutput = { + '@id': '/warehouse', + '@type': 'InventoryWarehouse', + id: 'warehouse-id', + code: 'VAN', + name: 'Van', + archived: false, + }; + type Draft = Pick; + + beforeAll(() => { + globalThis.ResizeObserver ??= class { + observe(): void {} + unobserve(): void {} + disconnect(): void {} + } as unknown as typeof ResizeObserver; + }); + + async function setup() { + TestBed.configureTestingModule({ imports: [InventoryConsumptionForm] }); + const fixture = TestBed.createComponent(InventoryConsumptionForm); + fixture.componentRef.setInput('parts', [part]); + fixture.componentRef.setInput('warehouses', [warehouse]); + await fixture.whenStable(); + const form = fixture.componentInstance as unknown as { + draft: { set: (draft: Draft) => void; (): Draft }; + submit: (event: Event) => void; + }; + const emitted = vi.fn(); + fixture.componentInstance.submitted.subscribe(emitted); + return { fixture, form, emitted }; + } + + it('emits six-place exact decimals beyond floating-point safe integers', async () => { + const { form, emitted, fixture } = await setup(); + form.draft.set({ + partId: part.id, + warehouseId: warehouse.id, + quantity: '999999999999999999.123456', + }); + await fixture.whenStable(); + form.submit(new Event('submit')); + expect(emitted).toHaveBeenCalledWith({ + partId: part.id, + warehouseId: warehouse.id, + quantity: '999999999999999999.123456', + }); + expect(form.draft().quantity).toBe('999999999999999999.123456'); + }); + + it.each(['0', '0.000000', '-1', '1e3', '1,5', '1000000000000000000', '1.1234567'])( + 'rejects invalid physical quantity %s', + async (quantity) => { + const { form, fixture, emitted } = await setup(); + form.draft.set({ partId: part.id, warehouseId: warehouse.id, quantity }); + await fixture.whenStable(); + form.submit(new Event('submit')); + expect(emitted).not.toHaveBeenCalled(); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toContain('Enter a positive decimal'); + }, + ); + + it('rejects archived references and keeps a failed draft until owner acceptance', async () => { + const { form, fixture, emitted } = await setup(); + const draft = { partId: part.id, warehouseId: warehouse.id, quantity: '0.000001' }; + form.draft.set(draft); + fixture.componentRef.setInput('parts', [{ ...part, archived: true }]); + fixture.componentRef.setInput('error', 'Storage unavailable'); + await fixture.whenStable(); + form.submit(new Event('submit')); + expect(emitted).not.toHaveBeenCalled(); + expect(form.draft()).toEqual(draft); + fixture.componentRef.setInput('resetKey', 1); + await fixture.whenStable(); + expect(form.draft()).toEqual({ partId: '', warehouseId: '', quantity: '' }); + }); + + it('blocks pending and uncertain submissions without clearing entered values', async () => { + const { form, fixture, emitted } = await setup(); + form.draft.set({ partId: part.id, warehouseId: warehouse.id, quantity: '1.5' }); + fixture.componentRef.setInput('pending', true); + await fixture.whenStable(); + form.submit(new Event('submit')); + expect(emitted).not.toHaveBeenCalled(); + fixture.componentRef.setInput('pending', false); + fixture.componentRef.setInput('locked', true); + await fixture.whenStable(); + form.submit(new Event('submit')); + expect(emitted).not.toHaveBeenCalled(); + expect(form.draft().quantity).toBe('1.5'); + }); +}); diff --git a/src/app/features/organization/features/inventory/ui/forms/inventory-movement-form/index.ts b/src/app/features/organization/features/inventory/ui/forms/inventory-movement-form/index.ts new file mode 100644 index 000000000..8528c8570 --- /dev/null +++ b/src/app/features/organization/features/inventory/ui/forms/inventory-movement-form/index.ts @@ -0,0 +1,4 @@ +export { + InventoryMovementForm, + type InventoryMovementDraft, +} from './inventory-movement-form.component'; diff --git a/src/app/features/organization/features/inventory/ui/forms/inventory-movement-form/inventory-movement-form.component.html b/src/app/features/organization/features/inventory/ui/forms/inventory-movement-form/inventory-movement-form.component.html new file mode 100644 index 000000000..85ef9ada4 --- /dev/null +++ b/src/app/features/organization/features/inventory/ui/forms/inventory-movement-form/inventory-movement-form.component.html @@ -0,0 +1,101 @@ +
+
+ Movement details +
+ @if (mode() === 'correction') { +
+ + + @if (fields.partId().touched()) { + @for (error of fields.partId().errors(); track error) { + {{ error.message }} + } + } +
+
+ + + @if (fields.warehouseId().touched()) { + @for (error of fields.warehouseId().errors(); track error) { + {{ error.message }} + } + } +
+ } +
+ + + @if (mode() === 'correction') { +

+ Use a negative quantity to remove stock, or a positive quantity to add it. +

+ } @else { +

+ Enter the quantity physically returned from this consumption. +

+ } + @if (fields.quantity().touched()) { + @for (error of fields.quantity().errors(); track error) { + {{ error.message }} + } + } +
+
+ + + @if (fields.reason().touched()) { + @for (error of fields.reason().errors(); track error) { + {{ error.message }} + } + } +
+
+
+ @if (error(); as message) { +
+

The movement could not be confirmed

+

{{ message }}

+
+ } +
+ + +
+
diff --git a/src/app/features/organization/features/inventory/ui/forms/inventory-movement-form/inventory-movement-form.component.ts b/src/app/features/organization/features/inventory/ui/forms/inventory-movement-form/inventory-movement-form.component.ts new file mode 100644 index 000000000..55750d7d8 --- /dev/null +++ b/src/app/features/organization/features/inventory/ui/forms/inventory-movement-form/inventory-movement-form.component.ts @@ -0,0 +1,304 @@ +import { + ChangeDetectionStrategy, + Component, + effect, + input, + output, + signal, + untracked, +} from '@angular/core'; +import { disabled, form, FormField, maxLength, required, validate } from '@angular/forms/signals'; +import { InventoryPartPicker } from '@features/organization/features/inventory/ui/components/inventory-part-picker'; +import { InventoryWarehousePicker } from '@features/organization/features/inventory/ui/components/inventory-warehouse-picker'; +import { normalizeInventoryQuantity } from '@features/organization/features/inventory/utils'; +import { HlmAlertImports } from '@shared/ui/alert'; +import { HlmButton } from '@shared/ui/button'; +import { HlmFieldImports } from '@shared/ui/field'; +import { HlmInput } from '@shared/ui/input'; +import { HlmSpinner } from '@shared/ui/spinner'; +import { HlmTextarea } from '@shared/ui/textarea'; + +/** + * Interface InventoryMovementDraft + * @interface InventoryMovementDraft + * + * @description + * Validated exact-decimal physical movement fields; the page supplies stable identity. + */ +export interface InventoryMovementDraft { + /** + * Property partId + * @readonly + * + * @description + * Selected active part. + * + * @access public + * @since unreleased + * + * @type {string} + */ + readonly partId: string; + /** + * Property warehouseId + * @readonly + * + * @description + * Selected active warehouse. + * + * @access public + * @since unreleased + * + * @type {string} + */ + readonly warehouseId: string; + /** + * Property quantity + * @readonly + * + * @description + * Signed correction or positive return quantity. + * + * @access public + * @since unreleased + * + * @type {string} + */ + readonly quantity: string; + /** + * Property reason + * @readonly + * + * @description + * Motivation preserved in the movement history. + * + * @access public + * @since unreleased + * + * @type {string} + */ + readonly reason: string; +} + +/** + * Class InventoryMovementForm + * + * @description + * A motivated stock correction or return emits exact strings without arithmetic. + */ +@Component({ + selector: 'app-inventory-movement-form', + templateUrl: './inventory-movement-form.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + FormField, + InventoryPartPicker, + InventoryWarehousePicker, + HlmButton, + HlmInput, + HlmTextarea, + HlmSpinner, + ...HlmFieldImports, + ...HlmAlertImports, + ], +}) +export class InventoryMovementForm { + /** + * Property organizationId + * @readonly + * + * @description + * Scope for authorized server choices. + * + * @access public + * @since unreleased + * + * @type {import('@angular/core').InputSignal} + */ + public readonly organizationId = input.required(); + /** + * Property mode + * @readonly + * + * @description + * Return quantity is positive, correction quantity is signed. + * + * @access public + * @since unreleased + * + * @type {import('@angular/core').InputSignal<'return' | 'correction'>} + */ + public readonly mode = input.required<'return' | 'correction'>(); + /** + * Property pending + * @readonly + * + * @description + * Progress while the page persists and transmits. + * + * @access public + * @since unreleased + * + * @type {import('@angular/core').InputSignal} + */ + public readonly pending = input(false); + /** + * Property available + * @readonly + * + * @description + * Caller supplies current operation-specific authorization. + * + * @access public + * @since unreleased + * + * @type {import('@angular/core').InputSignal} + */ + public readonly available = input(true); + /** + * Property error + * @readonly + * + * @description + * Rejection retains entered fields. + * + * @access public + * @since unreleased + * + * @type {import('@angular/core').InputSignal} + */ + public readonly error = input(null); + /** + * Property submitted + * @readonly + * + * @description + * Validated physical intention without operation identity. + * + * @access public + * @since unreleased + * + * @type {import('@angular/core').OutputEmitterRef} + */ + public readonly submitted = output(); + /** + * Property cancelled + * @readonly + * + * @description + * Requests dismissal before accepting the physical command. + * + * @access public + * @since unreleased + * + * @type {import('@angular/core').OutputEmitterRef} + */ + public readonly cancelled = output(); + /** + * Property dirtyChanged + * @readonly + * + * @description + * Actual draft edits protect dismissal before accepting the physical command. + * + * @access public + * @since unreleased + * + * @type {import('@angular/core').OutputEmitterRef} + */ + public readonly dirtyChanged = output(); + /** + * Property draft + * @readonly + * + * @description + * String-only draft. + * + * @access protected + * @since unreleased + * + * @type {import('@angular/core').WritableSignal<{ + * partId: string; + * warehouseId: string; + * quantity: string; + * reason: string; + * }>} + */ + protected readonly draft = signal({ partId: '', warehouseId: '', quantity: '', reason: '' }); + /** + * Property fields + * @readonly + * + * @description + * Exact quantity and nonblank reason validation. + * + * @access protected + * @since unreleased + * + * @type {import('@angular/forms/signals').FieldTree< + * { partId: string; warehouseId: string; quantity: string; reason: string }, + * string | number, + * 'writable' + * >} + */ + protected readonly fields = form(this.draft, (path) => { + disabled(path, () => this.pending() || !this.available()); + required(path.partId, { when: () => this.mode() === 'correction' }); + required(path.warehouseId, { when: () => this.mode() === 'correction' }); + required(path.quantity); + validate(path.quantity, ({ value }) => + normalizeInventoryQuantity(value(), this.mode() === 'correction') + ? null + : { + kind: 'quantity', + message: $localize`:@@inventory.movement.quantityInvalid:Enter a non-zero decimal with up to 18 whole digits and 6 decimal places, using a dot.`, + }, + ); + validate(path.reason, ({ value }) => + value().trim() + ? null + : { + kind: 'required', + message: $localize`:@@inventory.movement.reasonRequired:Explain why this movement is needed.`, + }, + ); + maxLength(path.reason, 2000); + }); + /** + * Constructor + * @constructor + * + * @description + * Publishes native form dirtiness before durable command acceptance. + * + * @access public + * @since unreleased + */ + public constructor() { + effect(() => { + const dirty = this.fields().dirty(); + untracked(() => this.dirtyChanged.emit(dirty)); + }); + } + /** + * Method submit + * + * @description + * Emits once with normalized exact decimals; the host persists the stable command. + * + * @access protected + * @since unreleased + * + * @param {Event} event - Native submit event. + * + * @returns {void} + */ + protected submit(event: Event): void { + event.preventDefault(); + this.fields().markAsTouched(); + if (this.pending() || !this.available() || this.fields().invalid()) return; + const draft = this.draft(), + quantity = normalizeInventoryQuantity(draft.quantity, this.mode() === 'correction'); + if (quantity) this.submitted.emit({ ...draft, quantity, reason: draft.reason.trim() }); + } +} diff --git a/src/app/features/organization/features/inventory/ui/forms/inventory-movement-form/testing/inventory-movement-form.component.spec.ts b/src/app/features/organization/features/inventory/ui/forms/inventory-movement-form/testing/inventory-movement-form.component.spec.ts new file mode 100644 index 000000000..2344015c0 --- /dev/null +++ b/src/app/features/organization/features/inventory/ui/forms/inventory-movement-form/testing/inventory-movement-form.component.spec.ts @@ -0,0 +1,123 @@ +import { TestBed, type ComponentFixture } from '@angular/core/testing'; +import { of } from 'rxjs'; +import { InventoryService } from '@features/organization/features/inventory/data-access'; +import { + InventoryMovementForm, + type InventoryMovementDraft, +} from '../inventory-movement-form.component'; + +describe('InventoryMovementForm', () => { + let fixture: ComponentFixture; + let writes: InventoryMovementDraft[]; + beforeAll(() => { + globalThis.ResizeObserver ??= class { + observe(): void {} + unobserve(): void {} + disconnect(): void {} + } as unknown as typeof ResizeObserver; + }); + + async function setup(mode: 'correction' | 'return'): Promise { + TestBed.configureTestingModule({ + imports: [InventoryMovementForm], + providers: [ + { + provide: InventoryService, + useValue: { + listParts: vi.fn().mockReturnValue(of({ member: [], totalItems: 0 })), + listWarehouses: vi.fn().mockReturnValue(of({ member: [], totalItems: 0 })), + readPart: vi.fn().mockReturnValue(of({ id: 'part', label: 'Seal', archived: false })), + readWarehouse: vi + .fn() + .mockReturnValue(of({ id: 'warehouse', name: 'Van', archived: false })), + }, + }, + ], + }); + fixture = TestBed.createComponent(InventoryMovementForm); + fixture.componentRef.setInput('organizationId', 'org'); + fixture.componentRef.setInput('mode', mode); + writes = []; + fixture.componentInstance.submitted.subscribe((value) => writes.push(value)); + await fixture.whenStable(); + } + async function submit(draft: InventoryMovementDraft): Promise { + fixture.componentInstance['draft'].set(draft); + await fixture.whenStable(); + const form = (fixture.nativeElement as HTMLElement).querySelector('form'); + if (!form) throw new Error('Missing movement form'); + form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); + await fixture.whenStable(); + } + const draft: InventoryMovementDraft = { + partId: 'part', + warehouseId: 'warehouse', + quantity: '1', + reason: 'Count correction', + }; + + it.each(['999999999999999999.123456', '-999999999999999999.123456'])( + 'preserves exact signed correction %s', + async (quantity) => { + await setup('correction'); + await submit({ ...draft, quantity, reason: ' Physical count ' }); + expect(writes).toEqual([{ ...draft, quantity, reason: 'Physical count' }]); + }, + ); + + it('pads correction decimal places without floating-point arithmetic', async () => { + await setup('correction'); + await submit({ ...draft, quantity: '-0.000001' }); + expect(writes[0]?.quantity).toBe('-0.000001'); + await submit({ ...draft, quantity: '1.5' }); + expect(writes[1]?.quantity).toBe('1.500000'); + }); + + it.each(['0', '-0.000000', '1.1234567', '1000000000000000000', '1e3', '1,5'])( + 'rejects invalid correction quantity %s without losing the draft', + async (quantity) => { + await setup('correction'); + await submit({ ...draft, quantity }); + expect(writes).toEqual([]); + expect(fixture.componentInstance['draft']().quantity).toBe(quantity); + }, + ); + + it('requires active reference UUIDs for corrections but not for a return linked by its owner', async () => { + await setup('correction'); + await submit({ ...draft, partId: '', warehouseId: '' }); + expect(writes).toEqual([]); + fixture.componentRef.setInput('mode', 'return'); + await fixture.whenStable(); + await submit({ ...draft, partId: '', warehouseId: '', quantity: '0.5' }); + expect(writes).toEqual([{ ...draft, partId: '', warehouseId: '', quantity: '0.500000' }]); + expect((fixture.nativeElement as HTMLElement).querySelector('app-inventory-part-picker')).toBe( + null, + ); + }); + + it('rejects negative return quantity and requires a nonblank bounded motivation', async () => { + await setup('return'); + await submit({ ...draft, quantity: '-1' }); + await submit({ ...draft, reason: ' ' }); + await submit({ ...draft, reason: 'x'.repeat(2001) }); + expect(writes).toEqual([]); + await submit({ ...draft, reason: 'x'.repeat(2000) }); + expect(writes).toHaveLength(1); + }); + + it('keeps entered physical facts on failure and disables pending or unavailable submissions', async () => { + await setup('return'); + fixture.componentRef.setInput('error', 'Network response lost'); + fixture.componentRef.setInput('pending', true); + await submit(draft); + expect(writes).toEqual([]); + expect(fixture.componentInstance['draft']()).toEqual(draft); + expect((fixture.nativeElement as HTMLElement).textContent).toContain('Network response lost'); + fixture.componentRef.setInput('pending', false); + fixture.componentRef.setInput('available', false); + await fixture.whenStable(); + await submit(draft); + expect(writes).toEqual([]); + }); +}); diff --git a/src/app/features/organization/features/inventory/ui/forms/inventory-reference-form/index.ts b/src/app/features/organization/features/inventory/ui/forms/inventory-reference-form/index.ts new file mode 100644 index 000000000..f6931b69c --- /dev/null +++ b/src/app/features/organization/features/inventory/ui/forms/inventory-reference-form/index.ts @@ -0,0 +1,4 @@ +export { + InventoryReferenceForm, + type InventoryReferenceSubmission, +} from './inventory-reference-form.component'; diff --git a/src/app/features/organization/features/inventory/ui/forms/inventory-reference-form/inventory-reference-form.component.html b/src/app/features/organization/features/inventory/ui/forms/inventory-reference-form/inventory-reference-form.component.html new file mode 100644 index 000000000..de94aaa75 --- /dev/null +++ b/src/app/features/organization/features/inventory/ui/forms/inventory-reference-form/inventory-reference-form.component.html @@ -0,0 +1,99 @@ +
+
+ Stock reference +
+
+ + +

+ Codes and part categories cannot be changed after creation. +

+ @if (fields.code().touched()) { + @for (error of fields.code().errors(); track error) { + {{ error.message }} + } + } +
+
+ + + @if (fields.label().touched()) { + @for (error of fields.label().errors(); track error) { + {{ error.message }} + } + } +
+ @if (kind() === 'part') { +
+ + +

+ For example: piece, metre or litre. Quantities use this unit. +

+ @if (fields.unit().touched()) { + @for (error of fields.unit().errors(); track error) { + {{ error.message }} + } + } +
+
+ + Category + + + + + +
+ } +
+
+ @if (error(); as message) { +
+

The reference could not be saved

+

{{ message }}

+
+ } +
+ + +
+
diff --git a/src/app/features/organization/features/inventory/ui/forms/inventory-reference-form/inventory-reference-form.component.ts b/src/app/features/organization/features/inventory/ui/forms/inventory-reference-form/inventory-reference-form.component.ts new file mode 100644 index 000000000..efd6d68f2 --- /dev/null +++ b/src/app/features/organization/features/inventory/ui/forms/inventory-reference-form/inventory-reference-form.component.ts @@ -0,0 +1,324 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + input, + output, + signal, + untracked, +} from '@angular/core'; +import { disabled, form, FormField, maxLength, validate } from '@angular/forms/signals'; +import type { + InventoryPartOutput, + InventoryWarehouseOutput, + CreateInventoryPartInput, + CreateInventoryWarehouseInput, +} from '@features/organization/features/inventory/models'; +import { HlmAlertImports } from '@shared/ui/alert'; +import { HlmButton } from '@shared/ui/button'; +import { HlmFieldImports } from '@shared/ui/field'; +import { HlmInput } from '@shared/ui/input'; +import { HlmSpinner } from '@shared/ui/spinner'; +import { HlmToggleGroupImports } from '@shared/ui/toggle-group'; + +/** + * Type InventoryReferenceSubmission + * + * @description + * Validated mutable description with permanent creation code and category. + * + * @type InventoryReferenceSubmission + */ +export type InventoryReferenceSubmission = CreateInventoryPartInput | CreateInventoryWarehouseInput; + +/** + * Class InventoryReferenceForm + * + * @description + * Native Signal Form creates references and retains unsuccessful edit drafts. + */ +@Component({ + selector: 'app-inventory-reference-form', + templateUrl: './inventory-reference-form.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + FormField, + HlmButton, + HlmInput, + HlmSpinner, + ...HlmFieldImports, + ...HlmAlertImports, + ...HlmToggleGroupImports, + ], +}) +export class InventoryReferenceForm { + /** + * Property kind + * @readonly + * + * @description + * Whether this editor describes a part or a warehouse. + * + * @access public + * @since unreleased + * + * @type {import('@angular/core').InputSignal<'part' | 'warehouse'>} + */ + public readonly kind = input.required<'part' | 'warehouse'>(); + /** + * Property entry + * @readonly + * + * @description + * Existing retained reference or fresh creation. + * + * @access public + * @since unreleased + * + * @type {import('@angular/core').InputSignal< + * InventoryPartOutput | InventoryWarehouseOutput | null + * >} + */ + public readonly entry = input(null); + /** + * Property pending + * @readonly + * + * @description + * Accepted command prevents changes and duplicate submit. + * + * @access public + * @since unreleased + * + * @type {import('@angular/core').InputSignal} + */ + public readonly pending = input(false); + /** + * Property available + * @readonly + * + * @description + * Authorization and connectivity supplied by page. + * + * @access public + * @since unreleased + * + * @type {import('@angular/core').InputSignal} + */ + public readonly available = input(true); + /** + * Property error + * @readonly + * + * @description + * Rejected command message, preserving the entered draft. + * + * @access public + * @since unreleased + * + * @type {import('@angular/core').InputSignal} + */ + public readonly error = input(null); + /** + * Property submitted + * @readonly + * + * @description + * Validated transport fields emitted without network calls. + * + * @access public + * @since unreleased + * + * @type {import('@angular/core').OutputEmitterRef} + */ + public readonly submitted = output(); + /** + * Property cancelled + * @readonly + * + * @description + * Parent decides draft dismissal. + * + * @access public + * @since unreleased + * + * @type {import('@angular/core').OutputEmitterRef} + */ + public readonly cancelled = output(); + /** + * Property dirtyChanged + * @readonly + * + * @description + * Actual native dirty state protects navigation. + * + * @access public + * @since unreleased + * + * @type {import('@angular/core').OutputEmitterRef} + */ + public readonly dirtyChanged = output(); + /** + * Property draft + * @readonly + * + * @description + * Unified local draft only, without invented API concurrency tokens. + * + * @access protected + * @since unreleased + * + * @type {import('@angular/core').WritableSignal<{ + * code: string; + * label: string; + * unit: string; + * kind: 'part' | 'consumable'; + * }>} + */ + protected readonly draft = signal({ + code: '', + label: '', + unit: 'piece', + kind: 'part' as 'part' | 'consumable', + }); + /** + * Property identity + * @readonly + * + * @description + * Stable editor identity avoids resetting the draft after a rejected save. + * + * @access private + * @since unreleased + * + * @type {import('@angular/core').Signal} + */ + private readonly identity = computed(() => this.kind() + ':' + (this.entry()?.id ?? 'new')); + /** + * Property fields + * @readonly + * + * @description + * Server-aligned required fields and immutable identity. + * + * @access protected + * @since unreleased + * + * @type {import('@angular/forms/signals').FieldTree< + * { code: string; label: string; unit: string; kind: 'part' | 'consumable' }, + * string | number, + * 'writable' + * >} + */ + protected readonly fields = form(this.draft, (path) => { + disabled(path, () => this.pending() || !this.available()); + disabled(path.code, () => !!this.entry()); + disabled(path.kind, () => !!this.entry() || this.kind() === 'warehouse'); + validate(path.code, ({ value }) => + value().trim() + ? null + : { + kind: 'required', + message: $localize`:@@inventory.form.codeRequired:Enter a permanent code.`, + }, + ); + validate(path.label, ({ value }) => + value().trim() + ? null + : { kind: 'required', message: $localize`:@@inventory.form.nameRequired:Enter a name.` }, + ); + validate(path.unit, ({ value }) => + this.kind() === 'warehouse' || value().trim() + ? null + : { + kind: 'required', + message: $localize`:@@inventory.form.unitRequired:Enter a stock unit.`, + }, + ); + maxLength(path.code, 100); + maxLength(path.label, 255); + maxLength(path.unit, 32); + }); + /** + * Constructor + * @constructor + * + * @description + * Seeds each identity once; request failures never clear entered fields. + * + * @access public + * @since unreleased + */ + public constructor() { + effect(() => { + this.identity(); + untracked(() => { + const entry = this.entry(); + this.fields().reset({ + code: entry?.code ?? '', + label: entry ? ('label' in entry ? entry.label : entry.name) : '', + unit: entry && 'unit' in entry ? entry.unit : 'piece', + kind: entry && 'kind' in entry ? entry.kind : 'part', + }); + }); + }); + effect(() => { + const dirty = this.fields().dirty(); + untracked(() => this.dirtyChanged.emit(dirty)); + }); + } + /** + * Method categoryChanged + * + * @description + * Connects the native two-option category to its Signal Form. + * + * @access protected + * @since unreleased + * + * @param {unknown} kind - Native category selection. + * + * @returns {void} + */ + protected categoryChanged(kind: unknown): void { + if ( + this.entry() || + this.pending() || + !this.available() || + (kind !== 'part' && kind !== 'consumable') + ) + return; + this.draft.update((draft) => ({ ...draft, kind })); + this.fields.kind().markAsDirty(); + } + /** + * Method submit + * + * @description + * Emits valid fields with immutable code and category on edits. + * + * @access protected + * @since unreleased + * + * @param {Event} event - Native submit event. + * + * @returns {void} + */ + protected submit(event: Event): void { + event.preventDefault(); + this.fields().markAsTouched(); + if (this.pending() || !this.available() || this.fields().invalid()) return; + const draft = this.draft(), + entry = this.entry(); + const code = entry?.code ?? draft.code.trim(); + if (this.kind() === 'warehouse') this.submitted.emit({ code, name: draft.label.trim() }); + else + this.submitted.emit({ + code, + label: draft.label.trim(), + unit: draft.unit.trim(), + kind: entry && 'kind' in entry ? entry.kind : draft.kind, + }); + } +} diff --git a/src/app/features/organization/features/inventory/ui/forms/inventory-reference-form/testing/inventory-reference-form.component.spec.ts b/src/app/features/organization/features/inventory/ui/forms/inventory-reference-form/testing/inventory-reference-form.component.spec.ts new file mode 100644 index 000000000..f4882b512 --- /dev/null +++ b/src/app/features/organization/features/inventory/ui/forms/inventory-reference-form/testing/inventory-reference-form.component.spec.ts @@ -0,0 +1,151 @@ +import { TestBed, type ComponentFixture } from '@angular/core/testing'; +import type { InventoryPartOutput } from '@features/organization/features/inventory/models'; +import { + InventoryReferenceForm, + type InventoryReferenceSubmission, +} from '../inventory-reference-form.component'; + +describe('InventoryReferenceForm', () => { + const part: InventoryPartOutput = { + '@id': '/part', + '@type': 'InventoryPart', + id: 'part', + code: 'SEAL', + label: 'Seal', + unit: 'piece', + kind: 'part', + archived: false, + }; + let fixture: ComponentFixture; + let writes: InventoryReferenceSubmission[]; + + function input(id: string): HTMLInputElement { + const field = (fixture.nativeElement as HTMLElement).querySelector( + '#inventory-reference-' + id, + ); + if (!field) throw new Error('Missing reference field ' + id); + return field; + } + async function fill(id: string, value: string): Promise { + const field = input(id); + field.value = value; + field.dispatchEvent(new Event('input', { bubbles: true })); + await fixture.whenStable(); + } + async function submit(): Promise { + const element = (fixture.nativeElement as HTMLElement).querySelector('form'); + if (!element) throw new Error('Missing reference form'); + element.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); + await fixture.whenStable(); + } + beforeEach(async () => { + TestBed.configureTestingModule({ imports: [InventoryReferenceForm] }); + fixture = TestBed.createComponent(InventoryReferenceForm); + fixture.componentRef.setInput('kind', 'part'); + writes = []; + fixture.componentInstance.submitted.subscribe((value) => writes.push(value)); + await fixture.whenStable(); + }); + + it('creates trimmed reference identity and selected consumable category', async () => { + await fill('code', ' SEAL '); + await fill('name', ' Seal '); + await fill('unit', ' piece '); + fixture.componentInstance['categoryChanged']('consumable'); + await submit(); + expect(writes).toEqual([{ code: 'SEAL', label: 'Seal', unit: 'piece', kind: 'consumable' }]); + }); + + it('creates warehouses without part-specific fields', async () => { + fixture.componentRef.setInput('kind', 'warehouse'); + await fixture.whenStable(); + await fill('code', ' VAN '); + await fill('name', ' Technician van '); + await submit(); + expect(writes).toEqual([{ code: 'VAN', name: 'Technician van' }]); + expect((fixture.nativeElement as HTMLElement).querySelector('#inventory-reference-unit')).toBe( + null, + ); + }); + + it.each(['code', 'name', 'unit'])( + 'rejects blank required %s without losing entered fields', + async (field) => { + await fill('code', 'SEAL'); + await fill('name', 'Seal'); + await fill('unit', 'piece'); + await fill(field, ' '); + await submit(); + expect(writes).toEqual([]); + expect(input(field).value).toBe(' '); + }, + ); + + it('locks permanent code/category and emits original identity even after attempted local replacement', async () => { + fixture.componentRef.setInput('entry', part); + await fixture.whenStable(); + expect(input('code').disabled).toBe(true); + fixture.componentInstance['categoryChanged']('consumable'); + fixture.componentInstance['draft'].set({ + code: 'CHANGED', + label: 'Updated seal', + unit: 'pack', + kind: 'consumable', + }); + await submit(); + expect(writes).toEqual([{ code: 'SEAL', label: 'Updated seal', unit: 'pack', kind: 'part' }]); + }); + + it('preserves a failed edit through a same-identity server refresh', async () => { + fixture.componentRef.setInput('entry', part); + await fixture.whenStable(); + await fill('name', 'Entered draft'); + fixture.componentRef.setInput('error', 'Reference unavailable'); + fixture.componentRef.setInput('entry', { ...part, label: 'Remote label' }); + await fixture.whenStable(); + expect(input('name').value).toBe('Entered draft'); + expect((fixture.nativeElement as HTMLElement).textContent).toContain('Reference unavailable'); + await submit(); + expect(writes).toEqual([{ code: 'SEAL', label: 'Entered draft', unit: 'piece', kind: 'part' }]); + }); + + it('seeds only a new record identity and keeps actual dirty state available to the host', async () => { + const dirty = vi.fn(); + fixture.componentInstance.dirtyChanged.subscribe(dirty); + await fill('name', 'Draft'); + expect(dirty).toHaveBeenLastCalledWith(true); + fixture.componentRef.setInput('entry', { + ...part, + id: 'other', + code: 'OTHER', + label: 'Other seal', + }); + await fixture.whenStable(); + expect(input('name').value).toBe('Other seal'); + expect(input('code').value).toBe('OTHER'); + expect(dirty).toHaveBeenLastCalledWith(false); + }); + + it('blocks pending and unauthorized writes while keeping an offline draft cancellable', async () => { + await fill('code', 'SEAL'); + await fill('name', 'Seal'); + fixture.componentRef.setInput('pending', true); + await fixture.whenStable(); + await submit(); + expect(writes).toEqual([]); + expect(input('name').disabled).toBe(true); + fixture.componentRef.setInput('pending', false); + fixture.componentRef.setInput('available', false); + await fixture.whenStable(); + const cancelled = vi.fn(); + fixture.componentInstance.cancelled.subscribe(cancelled); + const cancel = (fixture.nativeElement as HTMLElement).querySelector( + 'button[variant="outline"]', + ); + if (!cancel) throw new Error('Missing cancel button'); + expect(cancel.disabled).toBe(false); + cancel.click(); + expect(cancelled).toHaveBeenCalledTimes(1); + expect(input('name').value).toBe('Seal'); + }); +}); diff --git a/src/app/features/organization/features/inventory/ui/pages/inventory-page/index.ts b/src/app/features/organization/features/inventory/ui/pages/inventory-page/index.ts new file mode 100644 index 000000000..52a9792a9 --- /dev/null +++ b/src/app/features/organization/features/inventory/ui/pages/inventory-page/index.ts @@ -0,0 +1 @@ +export { InventoryPage } from './inventory-page.component'; diff --git a/src/app/features/organization/features/inventory/ui/pages/inventory-page/inventory-page.component.html b/src/app/features/organization/features/inventory/ui/pages/inventory-page/inventory-page.component.html new file mode 100644 index 000000000..6e163deb1 --- /dev/null +++ b/src/app/features/organization/features/inventory/ui/pages/inventory-page/inventory-page.component.html @@ -0,0 +1,382 @@ +
+
+

+ Track parts and consumables separately from individual equipment. Confirmed movements update + stock; retained declarations stay visible until reconciled. +

+ @if (canManage() && (section() === 'parts' || section() === 'warehouses')) { + + } @else if (canCorrect() && (section() === 'balances' || section() === 'movements')) { + + } +
+ +
+
+ @if (!online()) { +
+

+ Stock confirmation requires a connection +

+

+ Reconnect to read current stock or confirm a movement. Unacknowledged movements remain saved + on this device. +

+
+ } + @if (store.journalCallState().error; as error) { +
+

Saved movements could not be read

+

{{ error.message }}

+ +
+ } + @if (store.pendingCommandEntities().length > 0) { +
+

+ Movements awaiting confirmation +

+

+ The response may have been lost. Retry the saved movement to obtain confirmation with the + same identity. +

+ @for (command of store.pendingCommandEntities(); track command.input.clientOperationId) { +
+
+

+ @if (command.kind === 'return') { + Return + } @else { + Correction + } + : {{ command.input.quantity }} +

+

+ {{ command.input.reason }} +

+
+ +
+ } +
+ } + @if (canRead()) { +
+ @if (section() === 'parts' || section() === 'warehouses') { +
+ + +
+
+ + Reference status + + + + + +
+ } @else { +
+ + +
+
+ + +
+ @if (section() === 'consumptions') { +
+ + Declaration status + + + + + + +
+ } + } + +
+ @if (section() === 'consumptions') { +
+

+ A declaration is a retained physical fact +

+

+ A pending declaration does not debit part of the stock or complete the work. + Reconciliation confirms the entire quantity; dossier validation remains an intervention + decision. +

+
+ } + @if (store.listCallState().error; as error) { +
+

Stock records could not be loaded

+

{{ error.message }}

+ +
+ } + @if (!editor() && store.writeCallState().error; as error) { +
+

The operation could not be confirmed

+

{{ error.message }}

+
+ } + + } +
+@if (editor(); as kind) { + + +

{{ editorTitle() }}

+

+ Preserve stable references and record motivated stock movements without changing history. +

+
+ @if (retained(); as command) { +
+

This movement keeps one identity

+

+ {{ command.input.quantity }} · {{ command.input.reason }} +

+

+ Keep this identity when retrying. The stock is confirmed only by the server response. +

+
+ @if (store.writeCallState().error; as error) { +
+

+ The operation could not be confirmed +

+

{{ error.message }}

+
+ } +
+ + +
+ } @else if (kind === 'part' || kind === 'warehouse') { + + } @else { + @if (returning(); as consumption) { +

+ {{ store.partLabels()[consumption.partId] ?? consumption.partId }} · + {{ store.warehouseLabels()[consumption.warehouseId] ?? consumption.warehouseId }} +

+ } + + } +
+
+
+} + diff --git a/src/app/features/organization/features/inventory/ui/pages/inventory-page/inventory-page.component.ts b/src/app/features/organization/features/inventory/ui/pages/inventory-page/inventory-page.component.ts new file mode 100644 index 000000000..0fa44b10b --- /dev/null +++ b/src/app/features/organization/features/inventory/ui/pages/inventory-page/inventory-page.component.ts @@ -0,0 +1,1111 @@ +import { isPlatformBrowser } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + PLATFORM_ID, + computed, + effect, + inject, + input, + signal, + untracked, +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { form, FormField } from '@angular/forms/signals'; +import { RouterLink, RouterLinkActive } from '@angular/router'; +import { Events } from '@ngrx/signals/events'; +import type { BrnDialogState } from '@spartan-ng/brain/dialog'; +import { ConnectivityService } from '@core/connectivity'; +import { AUTH_SESSION_PORT } from '@features/auth/ports'; +import { OrganizationPermissionService } from '@features/organization/access'; +import type { + InventoryConsumptionOutput, + InventoryPartOutput, + InventoryWarehouseOutput, + InventoryPhysicalCommand, +} from '@features/organization/features/inventory/models'; +import { + InventoryStore, + inventoryEvents, + type InventorySection, + type InventoryQuery, +} from '@features/organization/features/inventory/state/inventory'; +import { InventoryPartPicker } from '@features/organization/features/inventory/ui/components/inventory-part-picker'; +import { InventoryWarehousePicker } from '@features/organization/features/inventory/ui/components/inventory-warehouse-picker'; +import { + InventoryDataview, + type InventoryRow, + type InventoryRowAction, +} from '@features/organization/features/inventory/ui/dataviews/inventory-dataview'; +import { + InventoryMovementForm, + type InventoryMovementDraft, +} from '@features/organization/features/inventory/ui/forms/inventory-movement-form'; +import { + InventoryReferenceForm, + type InventoryReferenceSubmission, +} from '@features/organization/features/inventory/ui/forms/inventory-reference-form'; +import { ORGANIZATION_PERMISSION } from '@features/organization/models'; +import { REGIONAL_FORMATTING_PORT } from '@features/organization/ports'; +import { ORGANIZATION_MEMBER_ACCESS_PORT } from '@features/organization/ports/organization-member-access'; +import { sheetSide } from '@shared/sheet-side'; +import { HlmAlertImports } from '@shared/ui/alert'; +import { HlmButton } from '@shared/ui/button'; +import { HlmFieldImports } from '@shared/ui/field'; +import { HlmInput } from '@shared/ui/input'; +import { HlmSheetImports } from '@shared/ui/sheet'; +import { HlmToggleGroupImports } from '@shared/ui/toggle-group'; +import { UnsavedChangesDialog, type UnsavedChangesAware } from '@shared/unsaved-changes'; + +/** + * Constant SECTION_LABELS + * + * @description + * Route tabs describe operational stock responsibilities. + */ +const SECTION_LABELS: Record = { + balances: $localize`:@@inventory.section.balances:Stock`, + parts: $localize`:@@inventory.section.parts:Parts and consumables`, + warehouses: $localize`:@@inventory.section.warehouses:Warehouses`, + consumptions: $localize`:@@inventory.section.consumptions:Consumption declarations`, + movements: $localize`:@@inventory.section.movements:Movement history`, +}; +/** + * Constant MOVEMENT_LABELS + * + * @description + * Wire movement kinds never become product labels. + */ +const MOVEMENT_LABELS = { + receipt: $localize`:@@inventory.movement.receipt:Receipt`, + receipt_return: $localize`:@@inventory.movement.supplierReturn:Supplier return`, + consumption: $localize`:@@inventory.movement.consumption:Consumption`, + return: $localize`:@@inventory.movement.return:Return`, + correction: $localize`:@@inventory.movement.correction:Correction`, +}; +/** + * Constant REASON_LABELS + * + * @description + * Pending stock explanations remain distinct from task completion. + */ +const REASON_LABELS = { + missing_balance: $localize`:@@inventory.reason.missing:No stock balance is available for this part and warehouse.`, + archived_reference: $localize`:@@inventory.reason.archived:This part or warehouse is archived. Review the retained declaration before reconciling.`, + insufficient_stock: $localize`:@@inventory.reason.shortage:The declared quantity exceeds available stock. The full declaration is retained without a partial debit.`, +}; + +/** + * Class InventoryPage + * + * @description + * Quantity-only stock directory with protected reference drafts and durable physical movement + * replay. + */ +@Component({ + selector: 'app-inventory-page', + templateUrl: './inventory-page.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [InventoryStore], + imports: [ + FormField, + RouterLink, + RouterLinkActive, + InventoryDataview, + InventoryReferenceForm, + InventoryMovementForm, + InventoryPartPicker, + InventoryWarehousePicker, + UnsavedChangesDialog, + HlmButton, + HlmInput, + ...HlmAlertImports, + ...HlmFieldImports, + ...HlmSheetImports, + ...HlmToggleGroupImports, + ], +}) +export class InventoryPage implements UnsavedChangesAware { + /** + * Property organizationId + * @readonly + * + * @description + * Organization authority from route binding. + * + * @access public + * @since unreleased + * + * @type {import('@angular/core').InputSignal} + */ + public readonly organizationId = input.required(); + /** + * Property section + * @readonly + * + * @description + * Route-owned directory section. + * + * @access public + * @since unreleased + * + * @type {import('@angular/core').InputSignal} + */ + public readonly section = input('balances'); + /** + * Property store + * @readonly + * + * @description + * Page-owned directory, command and journal states. + * + * @access protected + * @since unreleased + * + * @type {InventoryStoreType} + */ + protected readonly store = inject(InventoryStore); + /** + * Property permission + * @readonly + * + * @description + * Dedicated organization rights remain server-enforced. + * + * @access private + * @since unreleased + * + * @type {OrganizationPermissionService} + */ + private readonly permission = inject(OrganizationPermissionService); + /** + * Property member + * @readonly + * + * @description + * Published account membership for journal isolation. + * + * @access private + * @since unreleased + * + * @type {import('@features/organization/ports').OrganizationMemberAccessPort} + */ + private readonly member = inject(ORGANIZATION_MEMBER_ACCESS_PORT); + /** + * Property session + * @readonly + * + * @description + * Local generation invalidates obsolete page effects. + * + * @access private + * @since unreleased + * + * @type {import('@features/auth/ports').AuthSessionPort} + */ + private readonly session = inject(AUTH_SESSION_PORT); + /** + * Property connectivity + * @readonly + * + * @description + * Physical admin writes require online transmission; uncertain intentions stay durable. + * + * @access private + * @since unreleased + * + * @type {ConnectivityService} + */ + private readonly connectivity = inject(ConnectivityService); + /** + * Property browser + * @readonly + * + * @description + * Secondary stock data never loads during SSR. + * + * @access private + * @since unreleased + * + * @type {boolean} + */ + private readonly browser = isPlatformBrowser(inject(PLATFORM_ID)); + /** + * Property userId + * @readonly + * + * @description + * Only the active membership for the route organization is accepted. + * + * @access private + * @since unreleased + * + * @type {import('@angular/core').Signal} + */ + private readonly userId = computed(() => { + const member = this.member.profile(); + return this.session.isAuthenticated() && + member?.isActive && + member.organizationId === this.organizationId() + ? member.userId + : null; + }); + /** + * Property canRead + * @readonly + * + * @description + * Unknown access is not a read grant. + * + * @access protected + * @since unreleased + * + * @type {import('@angular/core').Signal} + */ + protected readonly canRead = computed(() => + this.permission.hasPermission(ORGANIZATION_PERMISSION.INVENTORY_READ), + ); + /** + * Property canManage + * @readonly + * + * @description + * Reference administration authorization. + * + * @access protected + * @since unreleased + * + * @type {import('@angular/core').Signal} + */ + protected readonly canManage = computed(() => + this.permission.hasPermission(ORGANIZATION_PERMISSION.INVENTORY_MANAGE), + ); + /** + * Property canReconcile + * @readonly + * + * @description + * Reconciliation requires inventory administration and intervention execution. + * + * @access protected + * @since unreleased + * + * @type {import('@angular/core').Signal} + */ + protected readonly canReconcile = computed( + () => + this.canManage() && + this.permission.hasPermission(ORGANIZATION_PERMISSION.INTERVENTIONS_EXECUTE), + ); + /** + * Property canReturn + * @readonly + * + * @description + * Physical returns require consumption and execution grants. + * + * @access protected + * @since unreleased + * + * @type {import('@angular/core').Signal} + */ + protected readonly canReturn = computed( + () => + this.permission.hasPermission(ORGANIZATION_PERMISSION.INVENTORY_CONSUME) && + this.permission.hasPermission(ORGANIZATION_PERMISSION.INTERVENTIONS_EXECUTE), + ); + /** + * Property canCorrect + * @readonly + * + * @description + * Stock corrections require dedicated financial management authorization. + * + * @access protected + * @since unreleased + * + * @type {import('@angular/core').Signal} + */ + protected readonly canCorrect = computed( + () => + this.canManage() && + this.permission.hasPermission(ORGANIZATION_PERMISSION.MAINTENANCE_COST_MANAGE), + ); + /** + * Property online + * @readonly + * + * @description + * Current network availability. + * + * @access protected + * @since unreleased + * + * @type {import('@angular/core').Signal} + */ + protected readonly online = computed(() => this.connectivity.isOnline()); + /** + * Property regionalFormatting + * @readonly + * + * @description + * Organization date pattern and timezone for occurrence history. + * + * @access protected + * @since unreleased + * + * @type {import('@angular/core').Signal< + * import('../../../../../../../shared/regional-format').RegionalFormatSettings + * >} + */ + protected readonly regionalFormatting = inject(REGIONAL_FORMATTING_PORT).regionalFormatting; + /** + * Property tabs + * @readonly + * + * @description + * Internal route navigation keeps quantities, declarations and history distinct. + * + * @access protected + * @since unreleased + * + * @type {{ value: string; label: string }[]} + */ + protected readonly tabs = Object.entries(SECTION_LABELS).map(([value, label]) => ({ + value, + label, + })); + /** + * Property filters + * @readonly + * + * @description + * Uncommitted filter draft; all matching is performed by the server. + * + * @access protected + * @since unreleased + * + * @type {import('@angular/core').WritableSignal<{ + * search: string; + * partId: string; + * warehouseId: string; + * archived: string; + * status: string; + * }>} + */ + protected readonly filters = signal({ + search: '', + partId: '', + warehouseId: '', + archived: 'active', + status: 'all', + }); + /** + * Property filterForm + * @readonly + * + * @description + * Native Signal Forms connect the reference pickers to server filters. + * + * @access protected + * @since unreleased + * + * @type {import('@angular/forms/signals').FieldTree< + * { search: string; partId: string; warehouseId: string; archived: string; status: string }, + * string | number, + * 'writable' + * >} + */ + protected readonly filterForm = form(this.filters); + /** + * Property editor + * @readonly + * + * @description + * One hosted native sheet for reference or physical-movement preparation. + * + * @access protected + * @since unreleased + * + * @type {import('@angular/core').WritableSignal< + * 'part' | 'warehouse' | 'return' | 'correction' | null + * >} + */ + protected readonly editor = signal<'part' | 'warehouse' | 'return' | 'correction' | null>(null); + /** + * Property editing + * @readonly + * + * @description + * Canonical reference being edited. + * + * @access protected + * @since unreleased + * + * @type {import('@angular/core').WritableSignal< + * InventoryPartOutput | InventoryWarehouseOutput | null + * >} + */ + protected readonly editing = signal(null); + /** + * Property returning + * @readonly + * + * @description + * Original confirmed consumption for a compensating physical return. + * + * @access protected + * @since unreleased + * + * @type {import('@angular/core').WritableSignal} + */ + protected readonly returning = signal(null); + /** + * Property retained + * @readonly + * + * @description + * A submitted physical body is immutable until server confirmation. + * + * @access protected + * @since unreleased + * + * @type {import('@angular/core').WritableSignal} + */ + protected readonly retained = signal(null); + /** + * Property retainedIsDurable + * @readonly + * + * @description + * A prepared identity is saved only after the journal commits it. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly retainedIsDurable = computed(() => { + const id = this.retained()?.input.clientOperationId; + return ( + !!id && + this.store.pendingCommandEntities().some((command) => command.input.clientOperationId === id) + ); + }); + /** + * Property dirty + * @readonly + * + * @description + * Reference draft dismissal protection. + * + * @access protected + * @since unreleased + * + * @type {import('@angular/core').WritableSignal} + */ + protected readonly dirty = signal(false); + /** + * Property confirmation + * @readonly + * + * @description + * Hosted native unsaved-input decision. + * + * @access protected + * @since unreleased + * + * @type {import('@angular/core').WritableSignal} + */ + protected readonly confirmation = signal('closed'); + /** + * Property resolver + * + * @description + * One route-deactivation decision at a time. + * + * @access private + * @since unreleased + * + * @type {((allowed: boolean) => void) | null} + */ + private resolver: ((allowed: boolean) => void) | null = null; + /** + * Property side + * @readonly + * + * @description + * Shared device-responsive sheet position. + * + * @access protected + * @since unreleased + * + * @type {import('@angular/core').Signal<'right' | 'bottom'>} + */ + protected readonly side = sheetSide(); + /** + * Property editorTitle + * @readonly + * + * @description + * Accessible native sheet title. + * + * @access protected + * @since unreleased + * + * @type {import('@angular/core').Signal} + */ + protected readonly editorTitle = computed(() => + this.editor() === 'part' + ? $localize`:@@inventory.editor.part:Part or consumable` + : this.editor() === 'warehouse' + ? $localize`:@@inventory.editor.warehouse:Warehouse` + : this.editor() === 'return' + ? $localize`:@@inventory.return:Record return` + : $localize`:@@inventory.editor.correction:Record stock correction`, + ); + /** + * Property rows + * @readonly + * + * @description + * Quantity-only localized projections keep operational state separate from work validation. + * + * @access protected + * @since unreleased + * + * @type {import('@angular/core').Signal} + */ + protected readonly rows = computed(() => + this.store.recordEntities().map((record) => { + if ('label' in record) + return { + record, + title: record.label, + detail: + record.code + + ' · ' + + (record.kind === 'part' + ? $localize`:@@inventory.kind.part:Part` + : $localize`:@@inventory.kind.consumable:Consumable`), + unit: record.unit, + status: record.archived + ? $localize`:@@inventory.archived:Archived` + : $localize`:@@inventory.active:Active`, + canEdit: this.canManage(), + archived: record.archived, + canArchive: this.canManage(), + }; + if ('name' in record) + return { + record, + title: record.name, + detail: record.code, + status: record.archived + ? $localize`:@@inventory.archived:Archived` + : $localize`:@@inventory.active:Active`, + canEdit: this.canManage(), + archived: record.archived, + canArchive: this.canManage(), + }; + const common = { + record, + title: this.store.partLabels()[record.partId] ?? record.partId, + detail: this.store.warehouseLabels()[record.warehouseId] ?? record.warehouseId, + quantity: record.quantity, + unit: this.store.partUnits()[record.partId], + }; + if ('status' in record) + return Object.assign({}, common, { + status: + record.status === 'confirmed' + ? $localize`:@@inventory.status.confirmed:Confirmed` + : $localize`:@@inventory.status.pending:Received — to reconcile`, + pending: record.status === 'received_pending', + date: record.occurredAt, + reason: record.reason ? REASON_LABELS[record.reason] : undefined, + late: record.late, + interventionId: record.interventionId, + canReconcile: record.status === 'received_pending' && this.canReconcile(), + canReturn: record.status === 'confirmed' && this.canReturn(), + }); + if ('kind' in record) + return Object.assign({}, common, { + status: MOVEMENT_LABELS[record.kind], + date: record.occurredAt, + reason: record.reason, + late: record.late, + interventionId: record.interventionId ?? undefined, + }); + return common; + }), + ); + /** + * Constructor + * @constructor + * + * @description + * Coordinates browser-only queries, scope clearing and confirmed-write refreshes. + * + * @access public + * @since unreleased + */ + public constructor() { + effect(() => { + const organizationId = this.organizationId(), + section = this.section(), + userId = this.userId(); + this.session.sessionRevision(); + untracked(() => { + this.closeEditor(); + this.filterForm().reset({ + search: '', + partId: '', + warehouseId: '', + archived: 'active', + status: 'all', + }); + this.store.load(null); + this.store.loadJournal(null); + if (this.browser && this.canRead() && userId) { + this.store.loadJournal({ organizationId, userId }); + if (this.online()) this.store.load({ organizationId, userId, section }); + } + }); + }); + effect(() => { + const allowed = this.canRead(), + online = this.online(), + userId = this.userId(); + untracked(() => { + if (!allowed || !userId) { + this.store.load(null); + this.store.loadJournal(null); + this.closeEditor(); + } else if (this.browser && online && !this.store.query()) { + this.store.loadJournal({ organizationId: this.organizationId(), userId }); + this.reload(); + } + }); + }); + inject(Events) + .on(inventoryEvents.saved) + .pipe(takeUntilDestroyed()) + .subscribe(({ payload }) => { + if (payload.organizationId !== this.organizationId() || payload.userId !== this.userId()) + return; + this.closeEditor(); + this.reload(); + }); + inject(DestroyRef).onDestroy(() => this.resolver?.(false)); + } + /** + * Method reload + * + * @description + * Retries the committed query without overwriting an editor draft. + * + * @access protected + * @since unreleased + * + * @returns {void} + */ + protected reload(): void { + const userId = this.userId(); + if (!this.browser || !this.canRead() || !userId || !this.online()) return; + this.store.load( + this.store.query() ?? { + organizationId: this.organizationId(), + userId, + section: this.section(), + }, + ); + } + /** + * Method applyFilters + * + * @description + * Commits reference search and stock filters to server page one. + * + * @access protected + * @since unreleased + * + * @param {Event | undefined} event - Optional native submit event. + * + * @returns {void} + */ + protected applyFilters(event?: Event): void { + event?.preventDefault(); + const userId = this.userId(); + if (!this.browser || !this.canRead() || !userId || !this.online()) return; + const filters = this.filters(); + const query: InventoryQuery = { + organizationId: this.organizationId(), + userId, + section: this.section(), + page: 1, + search: filters.search.trim() || undefined, + archived: filters.archived === 'archived', + partId: filters.partId || undefined, + warehouseId: filters.warehouseId || undefined, + status: + filters.status === 'confirmed' || filters.status === 'received_pending' + ? filters.status + : undefined, + }; + this.store.load(query); + } + /** + * Method filterChoice + * + * @description + * Native option sets update filters and issue one explicit query. + * + * @access protected + * @since unreleased + * + * @param {'archived' | 'status'} field - Controlled filter field. + * @param {unknown} value - Native toggle choice. + * + * @returns {void} + */ + protected filterChoice(field: 'archived' | 'status', value: unknown): void { + if (typeof value !== 'string') return; + if (field === 'archived' && value !== 'active' && value !== 'archived') return; + if ( + field === 'status' && + value !== 'all' && + value !== 'confirmed' && + value !== 'received_pending' + ) + return; + this.filters.update((draft) => ({ ...draft, [field]: value })); + this.applyFilters(); + } + /** + * Method changePage + * + * @description + * Uses the same committed filters for the next server page. + * + * @access protected + * @since unreleased + * + * @param {number} page - Requested page. + * + * @returns {void} + */ + protected changePage(page: number): void { + const query = this.store.query(); + if (query && this.online()) this.store.load({ ...query, page }); + } + /** + * Method open + * + * @description + * Starts an authorized editor without changing existing reference identity. + * + * @access protected + * @since unreleased + * + * @param {'part' | 'warehouse' | 'correction'} kind - Editor intent. + * @param {InventoryPartOutput | InventoryWarehouseOutput | null} entry - Existing reference. + * + * @returns {void} + */ + protected open( + kind: 'part' | 'warehouse' | 'correction', + entry: InventoryPartOutput | InventoryWarehouseOutput | null = null, + ): void { + if ( + !this.userId() || + !this.online() || + this.store.writeCallState().status === 'pending' || + (kind === 'correction' ? !this.canCorrect() : !this.canManage()) + ) + return; + this.store.clearWrite(); + this.editing.set(entry); + this.retained.set(null); + this.returning.set(null); + this.dirty.set(false); + this.editor.set(kind); + } + /** + * Method rowAction + * + * @description + * Checks the distinct permission for every action before accepting its command. + * + * @access protected + * @since unreleased + * + * @param {InventoryRowAction} action - Stateless row intention. + * + * @returns {void} + */ + protected rowAction(action: InventoryRowAction): void { + const userId = this.userId(); + if (!userId || !this.online() || this.store.writeCallState().status === 'pending') return; + const record = action.record, + scope = { organizationId: this.organizationId(), userId }; + if (action.kind === 'edit' && 'archived' in record && this.canManage()) + this.open('label' in record ? 'part' : 'warehouse', record); + else if (action.kind === 'archive' && 'archived' in record && this.canManage()) { + this.store.save({ + ...scope, + kind: 'label' in record ? 'updatePart' : 'updateWarehouse', + id: record.id, + input: { archived: !record.archived }, + }); + } else if ( + action.kind === 'reconcile' && + 'status' in record && + record.status === 'received_pending' && + this.canReconcile() + ) + this.store.save({ ...scope, kind: 'reconcile', id: record.id }); + else if ( + action.kind === 'return' && + 'status' in record && + record.status === 'confirmed' && + this.canReturn() + ) { + this.store.clearWrite(); + this.returning.set(record); + this.retained.set(null); + this.dirty.set(false); + this.editor.set('return'); + } + } + /** + * Method referenceSubmitted + * + * @description + * Only mutable fields are transported for existing references. + * + * @access protected + * @since unreleased + * + * @param {InventoryReferenceSubmission} submission - Validated draft. + * + * @returns {void} + */ + protected referenceSubmitted(submission: InventoryReferenceSubmission): void { + const userId = this.userId(); + if (!userId || !this.online() || !this.canManage()) return; + const scope = { organizationId: this.organizationId(), userId }, + entry = this.editing(); + if ('label' in submission) + this.store.save( + entry + ? { + ...scope, + kind: 'updatePart', + id: entry.id, + input: { label: submission.label, unit: submission.unit }, + } + : { ...scope, kind: 'createPart', input: submission }, + ); + else + this.store.save( + entry + ? { ...scope, kind: 'updateWarehouse', id: entry.id, input: { name: submission.name } } + : { ...scope, kind: 'createWarehouse', input: submission }, + ); + } + /** + * Method movementSubmitted + * + * @description + * Creates one immutable operation identity before durable acceptance and transmission. + * + * @access protected + * @since unreleased + * + * @param {InventoryMovementDraft} draft - Validated physical fields. + * + * @returns {void} + */ + protected movementSubmitted(draft: InventoryMovementDraft): void { + const userId = this.userId(); + if (!userId || !this.online() || this.retained()) return; + const scope = { organizationId: this.organizationId(), userId }, + clientOperationId = crypto.randomUUID(), + returning = this.returning(); + let command: InventoryPhysicalCommand; + if (this.editor() === 'return' && this.canReturn() && returning) + command = { + ...scope, + kind: 'return', + input: { + clientOperationId, + consumptionId: returning.id, + quantity: draft.quantity, + reason: draft.reason, + }, + }; + else if (this.editor() === 'correction' && this.canCorrect()) + command = { + ...scope, + kind: 'correction', + input: { + clientOperationId, + partId: draft.partId, + warehouseId: draft.warehouseId, + quantity: draft.quantity, + reason: draft.reason, + }, + }; + else return; + this.retained.set(command); + this.dirty.set(false); + this.store.save(command); + } + /** + * Method retryPhysical + * + * @description + * Replays the retained body with the original UUID; never prepares a second movement. + * + * @access protected + * @since unreleased + * + * @param {InventoryPhysicalCommand} command - Immutable local intention. + * + * @returns {void} + */ + protected retryPhysical(command: InventoryPhysicalCommand): void { + if ( + !this.online() || + command.userId !== this.userId() || + command.organizationId !== this.organizationId() || + (command.kind === 'return' ? !this.canReturn() : !this.canCorrect()) + ) + return; + this.store.save(command); + } + /** + * Method retryJournal + * + * @description + * Reloads only local journal errors without sending any pending operation. + * + * @access protected + * @since unreleased + * + * @returns {void} + */ + protected retryJournal(): void { + const userId = this.userId(); + if (this.browser && userId) + this.store.loadJournal({ organizationId: this.organizationId(), userId }); + } + /** + * Method closeEditor + * + * @description + * Clears transient editor state; durable pending intentions remain in the journal. + * + * @access private + * @since unreleased + * + * @returns {void} + */ + private closeEditor(): void { + this.editor.set(null); + this.editing.set(null); + this.returning.set(null); + this.retained.set(null); + this.dirty.set(false); + } + /** + * Method requestClose + * + * @description + * Accepted physical commands remain durable when the editor is dismissed. + * + * @access protected + * @since unreleased + * + * @returns {void} + */ + protected requestClose(): void { + if (this.store.writeCallState().status === 'pending') return; + if (this.dirty() || (this.retained() && !this.retainedIsDurable())) + this.confirmation.set('open'); + else this.closeEditor(); + } + /** + * Method stateChanged + * + * @description + * Native sheet dismissal obeys the same in-flight and draft guards. + * + * @access protected + * @since unreleased + * + * @param {BrnDialogState} state - Requested native state. + * + * @returns {void} + */ + protected stateChanged(state: BrnDialogState): void { + if (state === 'closed' && this.editor()) this.requestClose(); + } + /** + * Method hasUnsavedChanges + * + * @description + * Navigation protects transient drafts and in-flight commands. + * + * @access public + * @since unreleased + * + * @returns {boolean} Whether controlled deactivation is required. + */ + public hasUnsavedChanges(): boolean { + return ( + this.dirty() || + (this.retained() !== null && !this.retainedIsDurable()) || + this.store.writeCallState().status === 'pending' + ); + } + /** + * Method confirmDeactivation + * + * @description + * Blocks in-flight commands and hosts the native discard decision for reference drafts. + * + * @access public + * @since unreleased + * + * @returns {Promise} Operator decision. + */ + public confirmDeactivation(): Promise { + if (this.store.writeCallState().status === 'pending') return Promise.resolve(false); + this.confirmation.set('open'); + return new Promise((resolve) => { + this.resolver = resolve; + }); + } + /** + * Method resolveConfirmation + * + * @description + * Resolves editor and navigation confirmation exactly once. + * + * @access protected + * @since unreleased + * + * @param {boolean} allowed - Whether to discard transient input. + * + * @returns {void} + */ + protected resolveConfirmation(allowed: boolean): void { + this.confirmation.set('closed'); + if (allowed) this.closeEditor(); + this.resolver?.(allowed); + this.resolver = null; + } +} diff --git a/src/app/features/organization/features/inventory/ui/pages/inventory-page/testing/inventory-page.component.spec.ts b/src/app/features/organization/features/inventory/ui/pages/inventory-page/testing/inventory-page.component.spec.ts new file mode 100644 index 000000000..708bf419b --- /dev/null +++ b/src/app/features/organization/features/inventory/ui/pages/inventory-page/testing/inventory-page.component.spec.ts @@ -0,0 +1,443 @@ +import { PLATFORM_ID, signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import { Dispatcher, Events } from '@ngrx/signals/events'; +import { EMPTY, of, Subject, throwError } from 'rxjs'; +import { ConnectivityService } from '@core/connectivity'; +import { INTERACTION_CAPABILITIES_PORT } from '@core/interaction-capabilities'; +import { THEME_PORT } from '@core/theme'; +import { AUTH_SESSION_PORT } from '@features/auth/ports'; +import { OrganizationPermissionService } from '@features/organization/access'; +import { + InventoryCommandRepository, + InventoryService, +} from '@features/organization/features/inventory/data-access'; +import type { + InventoryConsumptionOutput, + InventoryMovementOutput, + InventoryPartOutput, + InventoryPhysicalCommand, +} from '@features/organization/features/inventory/models'; +import type { InventorySection } from '@features/organization/features/inventory/state/inventory'; +import { + ORGANIZATION_PERMISSION, + type CurrentOrganizationMemberProfileOutput, +} from '@features/organization/models'; +import { REGIONAL_FORMATTING_PORT } from '@features/organization/ports'; +import { ORGANIZATION_MEMBER_ACCESS_PORT } from '@features/organization/ports/organization-member-access'; +import { DEFAULT_REGIONAL_FORMAT_SETTINGS } from '@shared/regional-format'; +import { InventoryPage } from '../inventory-page.component'; + +describe('InventoryPage', () => { + const profile: CurrentOrganizationMemberProfileOutput = { + '@id': '/me', + '@type': 'Member', + id: 'member', + userId: 'user', + organizationId: 'org', + isActive: true, + joinedAt: '2026-10-06T10:00:00Z', + roles: [], + permissions: [], + }; + const part: InventoryPartOutput = { + '@id': '/part', + '@type': 'InventoryPart', + id: 'part', + code: 'SEAL', + label: 'Seal', + kind: 'part', + unit: 'piece', + archived: false, + }; + const consumption: InventoryConsumptionOutput = { + '@id': '/consumption', + '@type': 'InventoryConsumption', + id: 'consumption', + partId: 'part', + warehouseId: 'warehouse', + quantity: '2.000000', + interventionId: 'intervention', + actorId: 'actor', + occurredAt: '2026-10-06T10:00:00Z', + status: 'confirmed', + late: false, + replayed: false, + }; + const movement: InventoryMovementOutput = { + '@id': '/movement', + '@type': 'InventoryMovement', + id: 'movement', + partId: 'part', + warehouseId: 'warehouse', + quantity: '1.000000', + kind: 'correction', + reason: 'Physical count', + actorId: 'actor', + occurredAt: '2026-10-06T10:00:00Z', + late: false, + replayed: false, + }; + const draft = { + partId: 'part', + warehouseId: 'warehouse', + quantity: '-1.000001', + reason: 'Physical count', + }; + beforeAll(() => { + globalThis.ResizeObserver ??= class { + observe(): void {} + unobserve(): void {} + disconnect(): void {} + } as unknown as typeof ResizeObserver; + }); + + async function setup( + options: { + section?: InventorySection; + grants?: readonly string[]; + platform?: 'browser' | 'server'; + } = {}, + ) { + const grants = signal( + options.grants ?? [ORGANIZATION_PERMISSION.INVENTORY_READ], + ); + const online = signal(true); + const member = signal(profile); + const revision = signal(1); + const authenticated = signal(true); + const entries = new Map(); + const service = { + listParts: vi.fn().mockReturnValue(of({ member: [part], totalItems: 81 })), + listWarehouses: vi.fn().mockReturnValue(of({ member: [], totalItems: 0 })), + listBalances: vi.fn().mockReturnValue(of({ member: [], totalItems: 0 })), + listConsumptions: vi.fn().mockReturnValue(of({ member: [consumption], totalItems: 1 })), + listMovements: vi.fn().mockReturnValue(of({ member: [], totalItems: 0 })), + readPart: vi.fn().mockReturnValue(of(part)), + readWarehouse: vi.fn().mockReturnValue( + of({ + '@id': '/warehouse', + '@type': 'InventoryWarehouse', + id: 'warehouse', + code: 'VAN', + name: 'Van', + archived: false, + }), + ), + createPart: vi.fn().mockReturnValue(of(part)), + updatePart: vi.fn().mockReturnValue(of(part)), + createWarehouse: vi.fn().mockReturnValue(of({ id: 'warehouse' })), + updateWarehouse: vi.fn().mockReturnValue(of({ id: 'warehouse' })), + correctStock: vi.fn().mockReturnValue(of(movement)), + returnConsumption: vi.fn().mockReturnValue(of({ ...movement, kind: 'return' })), + reconcileConsumption: vi.fn().mockReturnValue(of(consumption)), + }; + const journal = { + sessionRevision: vi.fn(() => revision()), + isCurrent: vi.fn( + (userId: string, organizationId: string, sessionRevision = revision()) => + !!member()?.isActive && + authenticated() && + member()?.userId === userId && + member()?.organizationId === organizationId && + sessionRevision === revision(), + ), + readPending: vi.fn(async (userId: string, organizationId: string) => + [...entries.values()].filter( + (command) => command.userId === userId && command.organizationId === organizationId, + ), + ), + retain: vi.fn(async (command: InventoryPhysicalCommand) => { + entries.set(command.input.clientOperationId, command); + }), + acknowledge: vi.fn(async (command: InventoryPhysicalCommand) => { + entries.delete(command.input.clientOperationId); + }), + }; + const dispatcher = { dispatch: vi.fn() }; + TestBed.configureTestingModule({ + imports: [InventoryPage], + providers: [ + provideRouter([]), + { provide: PLATFORM_ID, useValue: options.platform ?? 'browser' }, + { provide: InventoryService, useValue: service }, + { provide: InventoryCommandRepository, useValue: journal }, + { + provide: OrganizationPermissionService, + useValue: { hasPermission: (permission: string) => grants().includes(permission) }, + }, + { provide: ORGANIZATION_MEMBER_ACCESS_PORT, useValue: { profile: member } }, + { + provide: REGIONAL_FORMATTING_PORT, + useValue: { regionalFormatting: signal(DEFAULT_REGIONAL_FORMAT_SETTINGS) }, + }, + { + provide: AUTH_SESSION_PORT, + useValue: { sessionRevision: revision, isAuthenticated: authenticated }, + }, + { provide: ConnectivityService, useValue: { isOnline: online } }, + { provide: THEME_PORT, useValue: { resolvedTheme: signal('light') } }, + { + provide: INTERACTION_CAPABILITIES_PORT, + useValue: { isMobileInteractionMode: signal(false) }, + }, + { provide: Events, useValue: { on: vi.fn().mockReturnValue(EMPTY) } }, + { provide: Dispatcher, useValue: dispatcher }, + ], + }); + const fixture = TestBed.createComponent(InventoryPage); + fixture.componentRef.setInput('organizationId', 'org'); + fixture.componentRef.setInput('section', options.section ?? 'parts'); + await fixture.whenStable(); + return { + fixture, + page: fixture.componentInstance, + grants, + online, + member, + revision, + entries, + service, + journal, + dispatcher, + }; + } + + it('loads the scoped active directory and keeps server search while changing pages', async () => { + const { page, fixture, service } = await setup(); + expect(service.listParts).toHaveBeenCalledWith( + 'org', + expect.objectContaining({ page: 1, itemsPerPage: 20, params: { archived: false } }), + ); + page['filters'].update((value) => ({ ...value, search: ' seal ' })); + page['applyFilters'](); + page['changePage'](3); + expect(service.listParts).toHaveBeenLastCalledWith( + 'org', + expect.objectContaining({ page: 3, search: 'seal', params: { archived: false } }), + ); + page['filterChoice']('archived', 'archived'); + await fixture.whenStable(); + expect(service.listParts).toHaveBeenLastCalledWith( + 'org', + expect.objectContaining({ page: 1, search: 'seal', params: { archived: true } }), + ); + expect((fixture.nativeElement as HTMLElement).textContent).toContain('Seal'); + }); + + it('clears old server pages and editor context when the route organization changes', async () => { + const { page, fixture, member, service } = await setup({ + grants: [ORGANIZATION_PERMISSION.INVENTORY_READ, ORGANIZATION_PERMISSION.INVENTORY_MANAGE], + }); + page['open']('part', part); + page['dirty'].set(true); + fixture.componentRef.setInput('organizationId', 'new-org'); + member.set({ ...profile, organizationId: 'new-org' }); + await fixture.whenStable(); + expect(page['editor']()).toBe(null); + expect(page['filters']().search).toBe(''); + expect(service.listParts).toHaveBeenLastCalledWith( + 'new-org', + expect.objectContaining({ params: { archived: false } }), + ); + }); + + it('suppresses both directory and picker reads during SSR', async () => { + const { service, journal } = await setup({ section: 'balances', platform: 'server' }); + expect(service.listBalances).not.toHaveBeenCalled(); + expect(service.listParts).not.toHaveBeenCalled(); + expect(service.listWarehouses).not.toHaveBeenCalled(); + expect(journal.readPending).not.toHaveBeenCalled(); + }); + + it('suppresses all authenticated stock reads without inventory read permission', async () => { + const { service, journal } = await setup({ section: 'balances', grants: [] }); + expect(service.listBalances).not.toHaveBeenCalled(); + expect(service.listParts).not.toHaveBeenCalled(); + expect(service.listWarehouses).not.toHaveBeenCalled(); + expect(journal.readPending).not.toHaveBeenCalled(); + }); + + it('keeps inventory administration separate from execution and financial authorization', async () => { + const { page, grants, fixture, service } = await setup({ + section: 'consumptions', + grants: [ORGANIZATION_PERMISSION.INVENTORY_READ, ORGANIZATION_PERMISSION.INVENTORY_MANAGE], + }); + expect(page['canManage']()).toBe(true); + expect(page['canCorrect']()).toBe(false); + expect(page['canReconcile']()).toBe(false); + expect(page['canReturn']()).toBe(false); + page['open']('correction'); + expect(page['editor']()).toBe(null); + const pending = { ...consumption, status: 'received_pending' as const }; + page['rowAction']({ kind: 'reconcile', record: pending }); + expect(service.reconcileConsumption).not.toHaveBeenCalled(); + grants.set([ + ORGANIZATION_PERMISSION.INVENTORY_READ, + ORGANIZATION_PERMISSION.INVENTORY_MANAGE, + ORGANIZATION_PERMISSION.INTERVENTIONS_EXECUTE, + ]); + await fixture.whenStable(); + expect(page['canReconcile']()).toBe(true); + expect(page['canCorrect']()).toBe(false); + page['rowAction']({ kind: 'reconcile', record: pending }); + expect(service.reconcileConsumption).toHaveBeenCalledWith('org', consumption.id); + }); + + it('requires both consumption and execution grants for a confirmed physical return', async () => { + const { page, grants, fixture, service } = await setup({ + section: 'consumptions', + grants: [ORGANIZATION_PERMISSION.INVENTORY_READ, ORGANIZATION_PERMISSION.INVENTORY_CONSUME], + }); + page['rowAction']({ kind: 'return', record: consumption }); + expect(page['editor']()).toBe(null); + grants.set([ + ORGANIZATION_PERMISSION.INVENTORY_READ, + ORGANIZATION_PERMISSION.INVENTORY_CONSUME, + ORGANIZATION_PERMISSION.INTERVENTIONS_EXECUTE, + ]); + await fixture.whenStable(); + page['rowAction']({ kind: 'return', record: { ...consumption, status: 'received_pending' } }); + expect(page['editor']()).toBe(null); + page['rowAction']({ kind: 'return', record: consumption }); + page['movementSubmitted']({ ...draft, quantity: '0.000001' }); + await fixture.whenStable(); + expect(service.returnConsumption).toHaveBeenCalledWith( + 'org', + expect.objectContaining({ + consumptionId: consumption.id, + quantity: '0.000001', + reason: draft.reason, + }), + ); + expect(service.returnConsumption.mock.calls[0]?.[1]).not.toHaveProperty('partId'); + }); + + it('updates descriptions without transporting a tampered permanent reference identity', async () => { + const { page, service, fixture } = await setup({ + grants: [ORGANIZATION_PERMISSION.INVENTORY_READ, ORGANIZATION_PERMISSION.INVENTORY_MANAGE], + }); + page['open']('part', part); + page['referenceSubmitted']({ + code: 'CHANGED', + label: 'Updated seal', + unit: 'pack', + kind: 'consumable', + }); + await fixture.whenStable(); + expect(service.updatePart).toHaveBeenCalledWith('org', part.id, { + label: 'Updated seal', + unit: 'pack', + }); + }); + + it('keeps an uncertain physical command durable after editor dismissal and retries its exact body', async () => { + const { page, service, journal, entries, fixture } = await setup({ + section: 'balances', + grants: [ + ORGANIZATION_PERMISSION.INVENTORY_READ, + ORGANIZATION_PERMISSION.INVENTORY_MANAGE, + ORGANIZATION_PERMISSION.MAINTENANCE_COST_MANAGE, + ], + }); + service.correctStock.mockReturnValueOnce(throwError(() => new Error('Network response lost'))); + page['open']('correction'); + page['movementSubmitted'](draft); + page['movementSubmitted'](draft); + await fixture.whenStable(); + expect(service.correctStock).toHaveBeenCalledTimes(1); + expect(journal.retain).toHaveBeenCalledTimes(1); + const command = page['retained'](); + if (!command) throw new Error('Expected retained physical command'); + expect(entries.get(command.input.clientOperationId)).toEqual(command); + expect(command.input.clientOperationId).toMatch(/^[0-9a-f-]{36}$/); + page['requestClose'](); + await fixture.whenStable(); + expect(page['editor']()).toBe(null); + expect(entries.get(command.input.clientOperationId)).toEqual(command); + page['retryPhysical'](command); + await fixture.whenStable(); + expect(service.correctStock).toHaveBeenCalledTimes(2); + expect(service.correctStock.mock.calls[1]?.[1]).toBe(service.correctStock.mock.calls[0]?.[1]); + expect(journal.acknowledge).toHaveBeenCalledWith(command, 1); + expect(entries.size).toBe(0); + }); + + it('requires an explicit discard if the physical intention could not be stored locally', async () => { + const { page, journal, service, fixture } = await setup({ + section: 'balances', + grants: [ + ORGANIZATION_PERMISSION.INVENTORY_READ, + ORGANIZATION_PERMISSION.INVENTORY_MANAGE, + ORGANIZATION_PERMISSION.MAINTENANCE_COST_MANAGE, + ], + }); + journal.retain.mockRejectedValueOnce(new Error('Storage full')); + page['open']('correction'); + page['movementSubmitted'](draft); + await fixture.whenStable(); + expect(service.correctStock).not.toHaveBeenCalled(); + expect(page['retainedIsDurable']()).toBe(false); + expect(page.hasUnsavedChanges()).toBe(true); + page['requestClose'](); + expect(page['confirmation']()).toBe('open'); + page['resolveConfirmation'](false); + expect(page['retained']()).not.toBe(null); + }); + + it('prevents dismissal during an accepted command and suppresses its UI effects after session replacement', async () => { + const { page, fixture, service, revision, dispatcher, journal } = await setup({ + section: 'balances', + grants: [ + ORGANIZATION_PERMISSION.INVENTORY_READ, + ORGANIZATION_PERMISSION.INVENTORY_MANAGE, + ORGANIZATION_PERMISSION.MAINTENANCE_COST_MANAGE, + ], + }); + const response = new Subject(); + service.correctStock.mockReturnValue(response); + page['open']('correction'); + page['movementSubmitted'](draft); + await fixture.whenStable(); + page['requestClose'](); + expect(page['editor']()).toBe('correction'); + expect(await page.confirmDeactivation()).toBe(false); + revision.set(3); + await fixture.whenStable(); + expect(page['editor']()).toBe(null); + response.next(movement); + await fixture.whenStable(); + expect(dispatcher.dispatch).not.toHaveBeenCalled(); + expect(journal.acknowledge).not.toHaveBeenCalled(); + }); + + it('guards dirty reference drafts and rejects unauthorized or offline command replay', async () => { + const { page, fixture, online, service } = await setup({ + grants: [ORGANIZATION_PERMISSION.INVENTORY_READ, ORGANIZATION_PERMISSION.INVENTORY_MANAGE], + }); + page['open']('part'); + page['dirty'].set(true); + page['requestClose'](); + expect(page['confirmation']()).toBe('open'); + page['resolveConfirmation'](false); + expect(page['editor']()).toBe('part'); + expect(page.hasUnsavedChanges()).toBe(true); + online.set(false); + await fixture.whenStable(); + page['referenceSubmitted']({ code: 'SEAL', label: 'Seal', unit: 'piece', kind: 'part' }); + page['retryPhysical']({ + kind: 'correction', + organizationId: 'org', + userId: 'user', + input: { + clientOperationId: 'existing', + partId: 'part', + warehouseId: 'warehouse', + quantity: '1.000000', + reason: 'Count', + }, + }); + expect(service.createPart).not.toHaveBeenCalled(); + expect(service.correctStock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/features/organization/features/inventory/utils/index.ts b/src/app/features/organization/features/inventory/utils/index.ts new file mode 100644 index 000000000..15d47f029 --- /dev/null +++ b/src/app/features/organization/features/inventory/utils/index.ts @@ -0,0 +1,2 @@ +export { normalizeInventoryQuantity } from './stock-quantity/stock-quantity.utils'; +export { quantityOnlyInventoryRecord } from './stock-quantity/quantity-only-inventory-record.utils'; diff --git a/src/app/features/organization/features/inventory/utils/stock-quantity/quantity-only-inventory-record.utils.ts b/src/app/features/organization/features/inventory/utils/stock-quantity/quantity-only-inventory-record.utils.ts new file mode 100644 index 000000000..5c51f852f --- /dev/null +++ b/src/app/features/organization/features/inventory/utils/stock-quantity/quantity-only-inventory-record.utils.ts @@ -0,0 +1,17 @@ +import type { HydraItem } from '@core/api/models'; + +/** + * Function quantityOnlyInventoryRecord + * + * @description + * Ordinary stock state strips dedicated financial valuations even for an authorized reader. + * + * @param {T} entry - Canonical transport resource. + * + * @returns {T} Copied record containing no valuation. + */ +export function quantityOnlyInventoryRecord(entry: T): T { + const copy = { ...entry }; + Reflect.deleteProperty(copy, 'valuation'); + return copy; +} diff --git a/src/app/features/organization/features/inventory/utils/stock-quantity/stock-quantity.utils.ts b/src/app/features/organization/features/inventory/utils/stock-quantity/stock-quantity.utils.ts new file mode 100644 index 000000000..138651e5b --- /dev/null +++ b/src/app/features/organization/features/inventory/utils/stock-quantity/stock-quantity.utils.ts @@ -0,0 +1,26 @@ +/** + * Function normalizeInventoryQuantity + * + * @description + * Validates and pads exact quantities through string operations; no floating-point arithmetic is + * used. + * + * @access public + * @since unreleased + * + * @param {string} value - Decimal text entered by the operator. + * @param {boolean} signed - Whether a motivated correction may decrease stock. + * + * @returns {string | null} Canonical six-place nonzero decimal, or null when invalid. + * + * @function normalizeInventoryQuantity + */ +export function normalizeInventoryQuantity(value: string, signed = false): string | null { + const raw = value.trim(); + const pattern = signed + ? /^-?(?:0|[1-9][0-9]{0,17})(?:\.[0-9]{1,6})?$/ + : /^(?:0|[1-9][0-9]{0,17})(?:\.[0-9]{1,6})?$/; + if (!pattern.test(raw) || /^-?0(?:\.0+)?$/.test(raw)) return null; + const [whole, fraction = ''] = raw.split('.'); + return `${whole}.${fraction.padEnd(6, '0')}`; +} diff --git a/src/app/features/organization/features/inventory/utils/stock-quantity/testing/stock-quantity.utils.spec.ts b/src/app/features/organization/features/inventory/utils/stock-quantity/testing/stock-quantity.utils.spec.ts new file mode 100644 index 000000000..a1b9ef000 --- /dev/null +++ b/src/app/features/organization/features/inventory/utils/stock-quantity/testing/stock-quantity.utils.spec.ts @@ -0,0 +1,40 @@ +import { normalizeInventoryQuantity } from '../stock-quantity.utils'; + +describe('normalizeInventoryQuantity', () => { + it.each([ + ['1', '1.000000'], + ['0.000001', '0.000001'], + [' 1.5 ', '1.500000'], + ['999999999999999999.123456', '999999999999999999.123456'], + ])('preserves exact positive quantity %s', (value, expected) => { + expect(normalizeInventoryQuantity(value)).toBe(expected); + }); + + it.each([ + '', + '0', + '0.000000', + '-0.000000', + '-1', + '1000000000000000000', + '1.1234567', + '1e3', + '1,5', + '01', + '+1', + 'Infinity', + 'NaN', + ])('rejects malformed or non-positive usage %s', (value) => { + expect(normalizeInventoryQuantity(value)).toBe(null); + }); + + it('permits signed adjustments while still rejecting zero and precision overflow', () => { + expect(normalizeInventoryQuantity('-999999999999999999.000001', true)).toBe( + '-999999999999999999.000001', + ); + expect(normalizeInventoryQuantity('-0.000001', true)).toBe('-0.000001'); + expect(normalizeInventoryQuantity('-0', true)).toBe(null); + expect(normalizeInventoryQuantity('-1000000000000000000', true)).toBe(null); + expect(normalizeInventoryQuantity('-1.0000001', true)).toBe(null); + }); +}); diff --git a/src/app/features/organization/features/maintenance-costs/FEATURE.md b/src/app/features/organization/features/maintenance-costs/FEATURE.md new file mode 100644 index 000000000..93db5744e --- /dev/null +++ b/src/app/features/organization/features/maintenance-costs/FEATURE.md @@ -0,0 +1,54 @@ +# Maintenance costs + +Organization-owned private operational finance. The feature publishes its `models`, `data-access`, +`state` and route tree `MAINTENANCE_COST_ROUTES`; Intervention may link to +`/organizations/:organizationId/maintenance-costs?interventionId=...` without embedding financial data. + +`organization.maintenance_cost.read` gates browser reads and page visibility. Management additionally +requires `.manage`; no financial request or payload enters SSR or TransferState. Route-scoped state +uses Auth's public `AUTH_SESSION_PORT` revision and Organization's published member actor to cancel +superseded reads, clear private data/drafts and ignore late replies after scope/session/account +changes. Accepted writes remain serialized and their replies +cannot populate another session. Currency and rates load only when the settings surface opens. + +Amounts remain exact decimal strings, including unknown/null values; display groups integer digits +without conversion to floating point. Current realized facts and the immutable private closure +snapshot remain separate. Financial planning uses its own revision, beginning at zero, and honors +the server's `planningEditable` capability. A stale draft is retained until the user explicitly +accepts the newly read planning revision. Expenses and rate retries keep the original identity and +payload after an uncertain response; adjustments reference the original expense. Expense and rate +intentions are account-bound in a feature-owned IndexedDB journal before transmission. Page +destruction, navigation and same-account reload retain exact retry; recovery must complete before +a new declaration. Only the original organization/dossier can recover its facts, and session +replacement purges the private journal without persisting credentials. A failed recovery keeps +new writes unavailable. Definite refusal or confirmation acknowledges the original intention. +The financial page's deactivation guard keeps an active write alive until its response settles; +an uncertain durable expense/rate may leave and recover when the original dossier is reopened. + +Forms are native Spartan Signal Forms and emit intent, while the page owns orchestration. The +feature consumes Organization's public permission service, member transport/helper and regional +formatting port. Member names/pickers load only with `organization.members.read`; missing names +never turn into editable UUID fields. The ordinary intervention dossier/PDF remains outside finance. + +The read-only `/maintenance-costs/reports` route owns economic pilotage by equipment, site or customer. +Dedicated financial transport returns exact full-filter totals and server-paginated allocation rows; +an oversized query is explicitly refused and never shown as a partial total. Each source fact is +counted once. Unknown valuations, missing historical snapshots and unallocated global work remain +explicit; global budgets and resource estimates are independent metrics. Current realized facts, +private immutable closure facts and their exact forecast variance retain separate columns. +The inclusive UTC date window selects publications by their immutable publication instant and open +work by planned start or creation when no start is set; it includes those dossiers' full current facts. +The source bound applies to the complete organization's date window before allocation filters, +including directly assigned material facts outside operational tasks. Oversized windows require +shorter dates; named filters cannot lift this bound. Estimated resources supply planned cost when +present, otherwise the global dossier budget supplies its forecast without adding both metrics. + +The minimal financial dossier directory is server-paginated and searchable, available with +`organization.maintenance_cost.read` alone. Its named site, customer and equipment selections +replace report scopes without contacting ordinary operational directories. Allocation source +navigation uses the report's exact bounded source identifiers and minimum names. Neither directory +contacts nor operational edit capabilities enter this contract. Closed secondary directory data +loads only after disclosure. Procurement uses an organization-wide order-creation date window and +the selected orders' full receipt/return histories, independently of target filters; those totals +never add to consumed-parts costs. Private queries and source names are absent from SSR/TransferState +and are cleared with drafts at organization/session replacement. diff --git a/src/app/features/organization/features/maintenance-costs/data-access/index.ts b/src/app/features/organization/features/maintenance-costs/data-access/index.ts new file mode 100644 index 000000000..5caaf85f6 --- /dev/null +++ b/src/app/features/organization/features/maintenance-costs/data-access/index.ts @@ -0,0 +1,3 @@ +export { MaintenanceCostService } from './services/maintenance-cost/maintenance-cost.service'; +export { MaintenanceCostCommandRepository } from './services/maintenance-cost-offline/maintenance-cost-command.repository'; +export { MaintenanceReportService } from './services/maintenance-report/maintenance-report.service'; diff --git a/src/app/features/organization/features/maintenance-costs/data-access/services/maintenance-cost-offline/maintenance-cost-command.repository.ts b/src/app/features/organization/features/maintenance-costs/data-access/services/maintenance-cost-offline/maintenance-cost-command.repository.ts new file mode 100644 index 000000000..226e0dcbd --- /dev/null +++ b/src/app/features/organization/features/maintenance-costs/data-access/services/maintenance-cost-offline/maintenance-cost-command.repository.ts @@ -0,0 +1,324 @@ +import { effect, inject, Service } from '@angular/core'; +import { IndexedDbService, type IndexedDbSchema } from '@core/indexed-db'; +import { AUTH_SESSION_PORT, type AuthSessionPort } from '@features/auth/ports'; +import type { MaintenanceCostCommand } from '@features/organization/features/maintenance-costs/models'; +import { + ORGANIZATION_MEMBER_ACCESS_PORT, + type OrganizationMemberAccessPort, +} from '@features/organization/ports/organization-member-access'; + +/** + * Class MaintenanceCostCommandRepository + * @class MaintenanceCostCommandRepository + * + * @description + * Account-bound journal of immutable expense and rate intentions, committed before transmission. + */ +@Service() +export class MaintenanceCostCommandRepository extends IndexedDbService { + //#region Properties + /** + * Property schema + * @readonly + * + * @description + * Independent private finance database requires no migration of another feature's records. + * + * @access protected + * @since unreleased + * + * @type {IndexedDbSchema} + */ + protected readonly schema: IndexedDbSchema = { + name: 'fireguard-maintenance-cost-commands', + version: 1, + storeNames: ['metadata', 'commands'], + ownerStoreName: 'metadata', + }; + + /** + * Property member + * @readonly + * + * @description + * Active account and organization binding, without persisted credentials. + * + * @access private + * @since unreleased + * + * @type {OrganizationMemberAccessPort} + */ + private readonly member: OrganizationMemberAccessPort = inject(ORGANIZATION_MEMBER_ACCESS_PORT); + + /** + * Property session + * @readonly + * + * @description + * In-memory session generation fences asynchronous journal work. + * + * @access private + * @since unreleased + * + * @type {AuthSessionPort} + */ + private readonly session: AuthSessionPort = inject(AUTH_SESSION_PORT); + + /** + * Property observedRevision + * + * @description + * Last session generation observed by imperative work and the lifecycle effect. + * + * @access private + * @since unreleased + * + * @type {number} + */ + private observedRevision: number = this.session.sessionRevision(); + + /** + * Property writes + * + * @description + * Serializes account binding, immutable intentions and logout purges. + * + * @access private + * @since unreleased + * + * @type {Promise} + */ + private writes: Promise = Promise.resolve(); + + /** + * Property purgeRequired + * + * @description + * Failed session purges stay mandatory before any subsequent private recovery or transmission. + * + * @access private + * @since unreleased + * + * @type {boolean} + */ + private purgeRequired: boolean = false; + //#endregion + + //#region Constructor + /** + * Constructor + * @constructor + * + * @description + * Retains reload recovery but purges private facts when an established session is replaced. + * + * @access public + * @since unreleased + */ + public constructor() { + super(); + effect(() => this.synchronizeSession()); + } + //#endregion + + //#region Methods + /** + * Method synchronizeSession + * @method synchronizeSession + * + * @description + * Starts a mandatory purge immediately when imperative work observes session replacement. + * + * @access private + * @since unreleased + * + * @returns {void} No return value. + */ + private synchronizeSession(): void { + const next = this.session.sessionRevision(); + if (!this.browser || this.observedRevision === next) return; + this.observedRevision = next; + this.purgeRequired = true; + this.writes = this.writes + .catch(() => undefined) + .then(async () => { + await this.resetOwnerData(); + this.purgeRequired = false; + }); + void this.writes.catch(() => undefined); + } + + /** + * Method captureOwner + * @method captureOwner + * + * @description + * Captures a non-secret actor identity for read and response fences in the owning store. + * + * @access public + * @since unreleased + * + * @param {string} organizationId - Displayed organization. + * @param {number} revision - Displayed session generation. + * + * @returns {string | null} Current actor, or no authorized membership. + */ + public captureOwner(organizationId: string, revision: number): string | null { + const userId = this.member.profile()?.userId ?? null; + return this.isCurrent(organizationId, revision, userId) ? userId : null; + } + + /** + * Method isCurrent + * @method isCurrent + * + * @description + * Rechecks account, membership, organization and the captured local session generation. + * + * @access public + * @since unreleased + * + * @param {string} organizationId - Original organization. + * @param {number} revision - Original session generation. + * @param {string | null} userId - Original account. + * + * @returns {boolean} Whether journal work still belongs to the active actor. + */ + public isCurrent(organizationId: string, revision: number, userId: string | null): boolean { + const profile = this.member.profile(); + return ( + this.browser && + this.session.isAuthenticated() && + revision === this.session.sessionRevision() && + !!profile?.isActive && + profile.organizationId === organizationId && + profile.userId === userId + ); + } + + /** + * Method readPending + * @method readPending + * + * @description + * Hydrates only this account's original dossier or organization rate; never auto-sends facts. + * + * @access public + * @since unreleased + * + * @param {string} organizationId - Active organization. + * @param {string | null} interventionId - Active financial dossier. + * + * @returns {Promise} Unresolved immutable intention. + */ + public async readPending( + organizationId: string, + interventionId: string | null, + ): Promise { + this.synchronizeSession(); + const userId = this.member.profile()?.userId ?? null; + const revision = this.session.sessionRevision(); + const current = () => this.isCurrent(organizationId, revision, userId); + if (!current() || !userId) return null; + await this.writes.catch(() => undefined); + if (this.purgeRequired) { + await this.resetOwnerData(); + this.purgeRequired = false; + } + await this.ensureOwnerBound(userId); + if (!current()) return null; + const commands = await this.getAll<{ + readonly userId: string; + readonly command: MaintenanceCostCommand; + }>('commands'); + if (!current()) return null; + return ( + commands.find( + (entry) => + entry.userId === userId && + entry.command.organizationId === organizationId && + (entry.command.kind === 'rate' || + (entry.command.kind === 'expense' && entry.command.interventionId === interventionId)), + )?.command ?? null + ); + } + + /** + * Method retain + * @method retain + * + * @description + * Freezes physical facts before network work and rejects a changed payload under an existing + * UUID. + * + * @access public + * @since unreleased + * + * @param {MaintenanceCostCommand} command - Original exact intention. + * + * @returns {Promise} Durable acceptance, or an explicit ownership/storage failure. + */ + public retain(command: MaintenanceCostCommand): Promise { + this.synchronizeSession(); + if (command.kind !== 'expense' && command.kind !== 'rate') return Promise.resolve(); + const frozen = structuredClone(command); + const userId = this.member.profile()?.userId ?? null; + const revision = this.session.sessionRevision(); + const current = () => this.isCurrent(frozen.organizationId, revision, userId); + const retained = this.writes + .catch(() => undefined) + .then(async () => { + if (!current() || !userId) throw new Error('Financial command ownership changed.'); + if (this.purgeRequired) { + await this.resetOwnerData(); + this.purgeRequired = false; + } + await this.ensureOwnerBound(userId); + if (!current()) throw new Error('Financial command ownership changed.'); + const key = `${frozen.kind}:${frozen.input.clientId}`; + const record = { userId, command: frozen }; + const previous = await this.get('commands', key); + if (previous && JSON.stringify(previous) !== JSON.stringify(record)) + throw new Error('Financial command identity belongs to a different intention.'); + if (previous) return; + await this.put('commands', key, record, current); + }); + this.writes = retained; + return retained; + } + + /** + * Method acknowledge + * @method acknowledge + * + * @description + * Removes a resolved intention only from its original active account and session. + * + * @access public + * @since unreleased + * + * @param {MaintenanceCostCommand} command - Confirmed or definitively refused intention. + * @param {number} revision - Original session generation. + * @param {string | null} userId - Original actor captured before transmission. + * + * @returns {Promise} Durable acknowledgement. + */ + public acknowledge( + command: MaintenanceCostCommand, + revision: number, + userId: string | null = this.member.profile()?.userId ?? null, + ): Promise { + this.synchronizeSession(); + if (command.kind !== 'expense' && command.kind !== 'rate') return Promise.resolve(); + const current = () => this.isCurrent(command.organizationId, revision, userId); + const acknowledged = this.writes + .catch(() => undefined) + .then(async () => { + if (current()) + await this.remove('commands', `${command.kind}:${command.input.clientId}`, current); + }); + this.writes = acknowledged; + return acknowledged; + } + //#endregion +} diff --git a/src/app/features/organization/features/maintenance-costs/data-access/services/maintenance-cost-offline/testing/maintenance-cost-command.repository.spec.ts b/src/app/features/organization/features/maintenance-costs/data-access/services/maintenance-cost-offline/testing/maintenance-cost-command.repository.spec.ts new file mode 100644 index 000000000..999b3fda1 --- /dev/null +++ b/src/app/features/organization/features/maintenance-costs/data-access/services/maintenance-cost-offline/testing/maintenance-cost-command.repository.spec.ts @@ -0,0 +1,244 @@ +import { PLATFORM_ID, signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { AUTH_SESSION_PORT } from '@features/auth/ports'; +import type { MaintenanceCostCommand } from '@features/organization/features/maintenance-costs/models'; +import type { CurrentOrganizationMemberProfileOutput } from '@features/organization/models'; +import { ORGANIZATION_MEMBER_ACCESS_PORT } from '@features/organization/ports/organization-member-access'; +import { MaintenanceCostCommandRepository } from '../maintenance-cost-command.repository'; + +describe('MaintenanceCostCommandRepository', () => { + const actor: CurrentOrganizationMemberProfileOutput = { + '@id': '/me', + '@type': 'OrganizationMember', + id: 'member-a', + userId: 'user-a', + organizationId: 'org-a', + isActive: true, + joinedAt: '2026-10-08T10:00:00Z', + roles: [], + permissions: [], + }; + const expense: MaintenanceCostCommand = { + kind: 'expense', + organizationId: 'org-a', + interventionId: 'dossier-a', + input: { + clientId: 'stable-expense', + amount: '9007199254740993.123456', + description: 'Original repair', + incurredAt: '2025-01-01T00:00:00Z', + }, + }; + + function setup(platform = 'browser') { + const profile = signal(actor); + const revision = signal(1); + const authenticated = signal(true); + TestBed.configureTestingModule({ + providers: [ + MaintenanceCostCommandRepository, + { provide: PLATFORM_ID, useValue: platform }, + { provide: ORGANIZATION_MEMBER_ACCESS_PORT, useValue: { profile } }, + { + provide: AUTH_SESSION_PORT, + useValue: { sessionRevision: revision, isAuthenticated: authenticated }, + }, + ], + }); + const repository = TestBed.inject(MaintenanceCostCommandRepository); + const entries = new Map([['metadata:ownerUserId', 'user-a']]); + vi.spyOn(repository, 'get').mockImplementation( + async (store: string, key: string): Promise => { + const value = entries.get(`${store}:${key}`); + return value === undefined ? null : (structuredClone(value) as T); + }, + ); + const getAll = vi + .spyOn(repository, 'getAll') + .mockImplementation(async (store: string): Promise => + [...entries.entries()] + .filter(([key]) => key.startsWith(`${store}:`)) + .map(([, value]) => structuredClone(value) as T), + ); + const put = vi + .spyOn(repository, 'put') + .mockImplementation(async (store, key, value, current) => { + if (current && !current()) throw new Error('Ownership changed'); + entries.set(`${store}:${key}`, structuredClone(value)); + }); + const remove = vi + .spyOn(repository, 'remove') + .mockImplementation(async (store, key, current) => { + if (current && !current()) throw new Error('Ownership changed'); + entries.delete(`${store}:${key}`); + }); + const clear = vi.spyOn(repository, 'clearAll').mockImplementation(async () => { + entries.clear(); + }); + return { repository, entries, profile, revision, authenticated, getAll, put, remove, clear }; + } + + it('retains exact immutable expense and rate UUIDs and permits only matching dossier recovery', async () => { + const { repository, entries } = setup(); + await repository.retain(expense); + expect(await repository.readPending('org-a', 'dossier-a')).toEqual(expense); + expect(await repository.readPending('org-a', 'dossier-b')).toBeNull(); + expect(await repository.readPending('org-b', 'dossier-a')).toBeNull(); + expect(entries.get('commands:expense:stable-expense')).toEqual({ + userId: 'user-a', + command: expense, + }); + const rate: MaintenanceCostCommand = { + kind: 'rate', + organizationId: 'org-a', + input: { + clientId: 'stable-rate', + memberId: 'member-a', + hourlyAmount: '50.123456', + effectiveFrom: '2025-01-01', + }, + }; + await repository.retain(rate); + expect(await repository.readPending('org-a', null)).toEqual(rate); + }); + + it('rejects a changed body under the same identity and retains the original facts', async () => { + const { repository } = setup(); + await repository.retain(expense); + await expect( + repository.retain({ ...expense, input: { ...expense.input, amount: '1' } }), + ).rejects.toThrow('different intention'); + expect(await repository.readPending('org-a', 'dossier-a')).toEqual(expense); + }); + + it('recovers the original account-bound command after the journal owner is recreated', async () => { + const { repository, entries } = setup(); + await repository.retain(expense); + const reloaded = TestBed.runInInjectionContext(() => new MaintenanceCostCommandRepository()); + vi.spyOn(reloaded, 'get').mockImplementation( + async (store: string, key: string): Promise => + (entries.get(`${store}:${key}`) as T | undefined) ?? null, + ); + vi.spyOn(reloaded, 'getAll').mockImplementation( + async (store: string): Promise => + [...entries.entries()] + .filter(([key]) => key.startsWith(`${store}:`)) + .map(([, value]) => structuredClone(value) as T), + ); + vi.spyOn(reloaded, 'put').mockImplementation(async (store, key, value) => { + entries.set(`${store}:${key}`, structuredClone(value)); + }); + vi.spyOn(reloaded, 'clearAll').mockImplementation(async () => { + entries.clear(); + }); + expect(reloaded).not.toBe(repository); + expect(await reloaded.readPending('org-a', 'dossier-a')).toEqual(expense); + expect(entries.get('commands:expense:stable-expense')).toEqual({ + userId: 'user-a', + command: expense, + }); + }); + + it('does not report acceptance before storage commits and propagates storage failures', async () => { + const { repository, put, entries } = setup(); + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + put.mockImplementation(async (store, key, value, current) => { + if (store === 'commands') await gate; + if (current && !current()) throw new Error('Ownership changed'); + entries.set(`${store}:${key}`, structuredClone(value)); + }); + let accepted = false; + const retaining = repository.retain(expense).then(() => { + accepted = true; + }); + await vi.waitFor(() => + expect(put).toHaveBeenCalledWith( + 'commands', + 'expense:stable-expense', + { userId: 'user-a', command: expense }, + expect.any(Function), + ), + ); + expect(accepted).toBe(false); + release(); + await retaining; + expect(accepted).toBe(true); + put.mockRejectedValueOnce(new DOMException('Quota exceeded', 'QuotaExceededError')); + await expect( + repository.retain({ ...expense, input: { ...expense.input, clientId: 'second-expense' } }), + ).rejects.toMatchObject({ name: 'QuotaExceededError' }); + }); + + it('purges an old account before a new account reads the journal', async () => { + const { repository, profile, entries, clear } = setup(); + await repository.retain(expense); + profile.set({ ...actor, userId: 'user-b' }); + expect(await repository.readPending('org-a', 'dossier-a')).toBeNull(); + expect(clear).toHaveBeenCalledTimes(1); + expect(entries.has('commands:expense:stable-expense')).toBe(false); + expect(entries.get('metadata:ownerUserId')).toBe('user-b'); + }); + + it('purges private facts at session replacement and fences an outstanding read', async () => { + const { repository, revision, entries, getAll } = setup(); + TestBed.tick(); + await repository.retain(expense); + let release!: (records: readonly unknown[]) => void; + getAll.mockReturnValueOnce( + new Promise((resolve) => { + release = resolve; + }), + ); + const reading = repository.readPending('org-a', 'dossier-a'); + await vi.waitFor(() => expect(getAll).toHaveBeenCalled()); + revision.set(2); + TestBed.tick(); + release([{ userId: 'user-a', command: expense }]); + expect(await reading).toBeNull(); + expect(await repository.readPending('org-a', 'dossier-a')).toBeNull(); + expect(entries.has('commands:expense:stable-expense')).toBe(false); + }); + + it('fences retention after logout or organization replacement and never touches storage in SSR', async () => { + const { repository, profile, put, authenticated } = setup(); + let release!: () => void; + vi.spyOn(repository, 'ensureOwnerBound').mockReturnValue( + new Promise((resolve) => { + release = resolve; + }), + ); + const retaining = repository.retain(expense); + const rejected = expect(retaining).rejects.toThrow('ownership changed'); + profile.set({ ...actor, organizationId: 'org-b' }); + authenticated.set(false); + release(); + await rejected; + expect(put).not.toHaveBeenCalled(); + }); + + it('does not open the private journal during SSR', async () => { + const { repository, put, getAll } = setup('server'); + expect(await repository.readPending('org-a', 'dossier-a')).toBeNull(); + await expect(repository.retain(expense)).rejects.toThrow('ownership changed'); + expect(put).not.toHaveBeenCalled(); + expect(getAll).not.toHaveBeenCalled(); + }); + + it('keeps a failed session purge mandatory before exposing any previous private intention', async () => { + const { repository, revision, entries, clear } = setup(); + await repository.retain(expense); + clear.mockRejectedValue(new Error('Purge unavailable')); + revision.set(2); + await expect(repository.readPending('org-a', 'dossier-a')).rejects.toThrow('Purge unavailable'); + await expect(repository.retain(expense)).rejects.toThrow('Purge unavailable'); + expect(entries.has('commands:expense:stable-expense')).toBe(true); + clear.mockImplementation(async () => { + entries.clear(); + }); + expect(await repository.readPending('org-a', 'dossier-a')).toBeNull(); + expect(entries.has('commands:expense:stable-expense')).toBe(false); + }); +}); diff --git a/src/app/features/organization/features/maintenance-costs/data-access/services/maintenance-cost/maintenance-cost.service.ts b/src/app/features/organization/features/maintenance-costs/data-access/services/maintenance-cost/maintenance-cost.service.ts new file mode 100644 index 000000000..89b1ecc40 --- /dev/null +++ b/src/app/features/organization/features/maintenance-costs/data-access/services/maintenance-cost/maintenance-cost.service.ts @@ -0,0 +1,201 @@ +import { Service } from '@angular/core'; +import type { Observable } from 'rxjs'; +import { HydraApiService } from '@core/api'; +import type { HydraCollection, RequestOptions } from '@core/api/models'; +import type { + MaintenanceCostOutput, + WriteMaintenanceCostPlanningInput, + CreateMaintenanceExpenseInput, + MaintenanceCurrencyOutput, + MaintenanceRateOutput, + CreateMaintenanceRateInput, +} from '@features/organization/features/maintenance-costs/models'; + +/** + * Class MaintenanceCostService + * @class MaintenanceCostService + * + * @description + * Dedicated financial endpoints preserve exact strings, stable identities and explicit revisions. + */ +@Service() +export class MaintenanceCostService extends HydraApiService { + //#region Methods + /** + * Method readCost + * @method readCost + * + * @description + * Reads current costs and the immutable private closure snapshot. + * + * @access public + * @since 2026-10-06 + * + * @param {string} organizationId - Owning organization UUID. + * @param {string} interventionId - Scoped intervention UUID. + * + * @returns {Observable} Authorized financial dossier. + */ + public readCost( + organizationId: string, + interventionId: string, + ): Observable { + return this.getOne( + `/api/organizations/${organizationId}/interventions/${interventionId}/costs`, + ); + } + + /** + * Method writePlanning + * @method writePlanning + * + * @description + * Updates only explicit forecast fields using the displayed independent revision, including + * revision zero. + * + * @access public + * @since 2026-10-06 + * + * @param {string} organizationId - Owning organization UUID. + * @param {string} interventionId - Scoped intervention UUID. + * @param {WriteMaintenanceCostPlanningInput} input - Explicit nullable forecast fields. + * @param {number} revision - Displayed forecast revision. + * + * @returns {Observable} Updated dedicated dossier. + */ + public writePlanning( + organizationId: string, + interventionId: string, + input: WriteMaintenanceCostPlanningInput, + revision: number, + ): Observable { + return this.patch( + `/api/organizations/${organizationId}/interventions/${interventionId}/costs/planning`, + input, + { headers: { 'If-Match': `"revision-${revision}"` } }, + ); + } + + /** + * Method createExpense + * @method createExpense + * + * @description + * Appends one expense or adjustment; a retry reuses its exact client identity and payload. + * + * @access public + * @since 2026-10-06 + * + * @param {string} organizationId - Owning organization UUID. + * @param {string} interventionId - Scoped intervention UUID. + * @param {CreateMaintenanceExpenseInput} input - Exact immutable declaration. + * + * @returns {Observable} Current costs with the original closure unchanged. + */ + public createExpense( + organizationId: string, + interventionId: string, + input: CreateMaintenanceExpenseInput, + ): Observable { + return this.post( + `/api/organizations/${organizationId}/interventions/${interventionId}/costs/expenses`, + input, + ); + } + + /** + * Method readCurrency + * @method readCurrency + * + * @description + * Reads the chosen currency and its server lock. + * + * @access public + * @since 2026-10-06 + * + * @param {string} organizationId - Owning organization UUID. + * + * @returns {Observable} Current organization currency. + */ + public readCurrency(organizationId: string): Observable { + return this.getOne( + `/api/organizations/${organizationId}/maintenance-cost/currency`, + ); + } + + /** + * Method writeCurrency + * @method writeCurrency + * + * @description + * Updates the organization currency before its first financial fact. + * + * @access public + * @since 2026-10-06 + * + * @param {string} organizationId - Owning organization UUID. + * @param {string} currency - Uppercase three-letter currency. + * + * @returns {Observable} Confirmed currency and lock. + */ + public writeCurrency( + organizationId: string, + currency: string, + ): Observable { + return this.patch<{ readonly currency: string }, MaintenanceCurrencyOutput>( + `/api/organizations/${organizationId}/maintenance-cost/currency`, + { currency }, + ); + } + + /** + * Method listRates + * @method listRates + * + * @description + * Lists the immutable rate history using server pagination and optional member scope. + * + * @access public + * @since 2026-10-06 + * + * @param {string} organizationId - Owning organization UUID. + * @param {RequestOptions | undefined} options - Server page and member filter. + * + * @returns {Observable>} Bounded authorized rate page. + */ + public listRates( + organizationId: string, + options?: RequestOptions, + ): Observable> { + return this.getCollection( + `/api/organizations/${organizationId}/maintenance-cost/rates`, + options, + ); + } + + /** + * Method createRate + * @method createRate + * + * @description + * Appends an effective rate without replacing earlier rates. + * + * @access public + * @since 2026-10-06 + * + * @param {string} organizationId - Owning organization UUID. + * @param {CreateMaintenanceRateInput} input - Stable UUID and exact immutable rate declaration. + * + * @returns {Observable} Created rate or exact replay. + */ + public createRate( + organizationId: string, + input: CreateMaintenanceRateInput, + ): Observable { + return this.post( + `/api/organizations/${organizationId}/maintenance-cost/rates`, + input, + ); + } + //#endregion +} diff --git a/src/app/features/organization/features/maintenance-costs/data-access/services/maintenance-cost/testing/maintenance-cost.service.spec.ts b/src/app/features/organization/features/maintenance-costs/data-access/services/maintenance-cost/testing/maintenance-cost.service.spec.ts new file mode 100644 index 000000000..bcbdc81db --- /dev/null +++ b/src/app/features/organization/features/maintenance-costs/data-access/services/maintenance-cost/testing/maintenance-cost.service.spec.ts @@ -0,0 +1,113 @@ +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { ENV_CONFIG } from '@core/config/environment/env.token'; +import { maintenanceCostFixture } from '@features/organization/features/maintenance-costs/models/maintenance-cost/testing/maintenance-cost.fixture'; +import { MaintenanceCostService } from '../maintenance-cost.service'; + +describe('MaintenanceCostService financial contract', () => { + let service: MaintenanceCostService; + let http: HttpTestingController; + const base = 'https://api.test/api/organizations/org'; + const cost = maintenanceCostFixture(); + const costUrl = base + '/interventions/' + cost.interventionId + '/costs'; + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + MaintenanceCostService, + { provide: ENV_CONFIG, useValue: { apiUrl: 'https://api.test' } }, + ], + }); + service = TestBed.inject(MaintenanceCostService); + http = TestBed.inject(HttpTestingController); + }); + afterEach(() => http.verify()); + it('reads a dedicated private dossier without using the ordinary intervention endpoint', () => { + service.readCost('org', cost.interventionId).subscribe(); + const request = http.expectOne(costUrl); + expect(request.request.method).toBe('GET'); + expect(request.request.withCredentials).toBe(true); + request.flush(cost); + }); + it('sends independent quoted revision zero and exact nullable preparation', () => { + const input = { + plannedBudget: '9007199254740993.123456', + estimatedMinutes: null, + resources: [], + }; + service.writePlanning('org', cost.interventionId, input, 0).subscribe(); + const request = http.expectOne(costUrl + '/planning'); + expect(request.request.method).toBe('PATCH'); + expect(request.request.headers.get('If-Match')).toBe('"revision-0"'); + expect(request.request.headers.get('Content-Type')).toBe('application/merge-patch+json'); + expect(request.request.body).toEqual(input); + request.flush(cost); + }); + it('appends motivated signed corrections with unchanged replay and original identifiers', () => { + const input = { + clientId: '12345678-1234-4234-8234-123456789abd', + amount: '-0.000001', + description: 'Refund original fee', + incurredAt: '2025-01-01T12:34:56Z', + adjustmentOf: 'original', + workItemId: 'task', + }; + service.createExpense('org', cost.interventionId, input).subscribe(); + const request = http.expectOne(costUrl + '/expenses'); + expect(request.request.method).toBe('POST'); + expect(request.request.body).toEqual(input); + request.flush(cost); + }); + it('reads currency and rate history in organization scope with server pagination', () => { + service.readCurrency('org').subscribe(); + http + .expectOne(base + '/maintenance-cost/currency') + .flush({ organizationId: 'org', currency: 'EUR', locked: true }); + service + .listRates('org', { page: 2, itemsPerPage: 30, params: { memberId: 'member' } }) + .subscribe(); + const request = http.expectOne( + (candidate) => candidate.url === base + '/maintenance-cost/rates', + ); + expect(request.request.params.get('page')).toBe('2'); + expect(request.request.params.get('itemsPerPage')).toBe('30'); + expect(request.request.params.get('memberId')).toBe('member'); + request.flush({ member: [], totalItems: 0 }); + }); + it('sends a dated exact rate and propagates an immutable receipt', () => { + const input = { + clientId: '12345678-1234-4234-8234-123456789abe', + memberId: 'member', + hourlyAmount: '9007199254740993.123456', + effectiveFrom: '2026-10-06', + }; + service.createRate('org', input).subscribe(); + const request = http.expectOne(base + '/maintenance-cost/rates'); + expect(request.request.body).toEqual(input); + request.flush({ id: input.clientId, ...input, currency: 'EUR', replayed: true }); + }); + it('propagates a planning precondition failure to retain and compare the draft', () => { + let failure: unknown; + service + .writePlanning( + 'org', + cost.interventionId, + { plannedBudget: null, estimatedMinutes: null, resources: [] }, + 2, + ) + .subscribe({ + error: (error: unknown) => { + failure = error; + }, + }); + const request = http.expectOne(costUrl + '/planning'); + expect(request.request.headers.get('If-Match')).toBe('"revision-2"'); + request.flush( + { title: 'Stale planning', detail: 'Read the latest revision.' }, + { status: 412, statusText: 'Precondition Failed' }, + ); + expect(failure).toMatchObject({ status: 412, detail: 'Read the latest revision.' }); + }); +}); diff --git a/src/app/features/organization/features/maintenance-costs/data-access/services/maintenance-report/maintenance-report.service.ts b/src/app/features/organization/features/maintenance-costs/data-access/services/maintenance-report/maintenance-report.service.ts new file mode 100644 index 000000000..467bb40e7 --- /dev/null +++ b/src/app/features/organization/features/maintenance-costs/data-access/services/maintenance-report/maintenance-report.service.ts @@ -0,0 +1,98 @@ +import { Service } from '@angular/core'; +import type { Observable } from 'rxjs'; +import { HydraApiService } from '@core/api'; +import type { HydraCollection } from '@core/api/models'; +import type { + MaintenanceEconomicReportOutput, + MaintenanceFinancialDirectoryQuery, + MaintenanceFinancialDossierOutput, + MaintenanceReportQuery, +} from '@features/organization/features/maintenance-costs/models'; + +/** + * Class MaintenanceReportService + * @class MaintenanceReportService + * + * @description + * Dedicated read-only financial transport; server aggregates and scope identities remain + * authoritative. + */ +@Service() +export class MaintenanceReportService extends HydraApiService { + //#region Methods + /** + * Method readReport + * @method readReport + * + * @description + * Reads complete filtered totals and one allocation page; an oversized scope is explicitly + * rejected. + * + * @access public + * @since unreleased + * + * @param {string} organizationId - Authorized owning organization. + * @param {MaintenanceReportQuery} query - Inclusive dates, allocation dimension and server page. + * + * @returns {Observable} Exact private financial report. + */ + public readReport( + organizationId: string, + query: MaintenanceReportQuery, + ): Observable { + return this.getOne( + `/api/organizations/${organizationId}/maintenance-cost/reports`, + { + params: { + from: query.from, + to: query.to, + groupBy: query.groupBy, + page: query.page, + itemsPerPage: query.itemsPerPage, + ...(query.siteId ? { siteId: query.siteId } : {}), + ...(query.customerId ? { customerId: query.customerId } : {}), + ...(query.equipmentId ? { equipmentId: query.equipmentId } : {}), + }, + }, + ); + } + + /** + * Method listDossiers + * @method listDossiers + * + * @description + * Browses minimal named financial contexts with cost-read permission independently of operational + * reads. + * + * @access public + * @since unreleased + * + * @param {string} organizationId - Authorized owning organization. + * @param {MaintenanceFinancialDirectoryQuery} query - Search, optional scope and explicit page. + * + * @returns {Observable>} Authorized minimal + * directory page. + */ + public listDossiers( + organizationId: string, + query: MaintenanceFinancialDirectoryQuery, + ): Observable> { + return this.getCollection( + `/api/organizations/${organizationId}/maintenance-cost/dossiers`, + { + page: query.page, + itemsPerPage: query.itemsPerPage, + search: query.search, + params: { + ...(query.from ? { from: query.from } : {}), + ...(query.to ? { to: query.to } : {}), + ...(query.siteId ? { siteId: query.siteId } : {}), + ...(query.customerId ? { customerId: query.customerId } : {}), + ...(query.equipmentId ? { equipmentId: query.equipmentId } : {}), + }, + }, + ); + } + //#endregion +} diff --git a/src/app/features/organization/features/maintenance-costs/data-access/services/maintenance-report/testing/maintenance-report.service.spec.ts b/src/app/features/organization/features/maintenance-costs/data-access/services/maintenance-report/testing/maintenance-report.service.spec.ts new file mode 100644 index 000000000..31e70b34b --- /dev/null +++ b/src/app/features/organization/features/maintenance-costs/data-access/services/maintenance-report/testing/maintenance-report.service.spec.ts @@ -0,0 +1,115 @@ +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { ENV_CONFIG } from '@core/config/environment/env.token'; +import { maintenanceReportFixture } from '@features/organization/features/maintenance-costs/models/report/testing/maintenance-report.fixture'; +import { MaintenanceReportService } from '../maintenance-report.service'; + +describe('MaintenanceReportService', () => { + let service: MaintenanceReportService; + let http: HttpTestingController; + const endpoint = 'https://api.test/api/organizations/org/maintenance-cost'; + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + MaintenanceReportService, + { provide: ENV_CONFIG, useValue: { apiUrl: 'https://api.test' } }, + ], + }); + service = TestBed.inject(MaintenanceReportService); + http = TestBed.inject(HttpTestingController); + }); + afterEach(() => http.verify()); + it('sends inclusive dates, target filters and pagination to the dedicated exact aggregate', () => { + service + .readReport('org', { + from: '2025-01-01', + to: '2025-01-31', + groupBy: 'equipment', + page: 2, + itemsPerPage: 30, + siteId: 'site', + customerId: 'customer', + equipmentId: 'equipment', + }) + .subscribe(); + const request = http.expectOne((candidate) => candidate.url === endpoint + '/reports'); + expect(request.request.method).toBe('GET'); + expect(request.request.withCredentials).toBe(true); + expect(request.request.params.keys().toSorted()).toEqual([ + 'customerId', + 'equipmentId', + 'from', + 'groupBy', + 'itemsPerPage', + 'page', + 'siteId', + 'to', + ]); + expect(request.request.params.get('groupBy')).toBe('equipment'); + expect(request.request.params.get('page')).toBe('2'); + expect(request.request.params.get('itemsPerPage')).toBe('30'); + expect(request.request.params.get('from')).toBe('2025-01-01'); + expect(request.request.params.get('to')).toBe('2025-01-31'); + expect(request.request.params.get('equipmentId')).toBe('equipment'); + request.flush(maintenanceReportFixture()); + }); + it('never invents an unfiltered target parameter or reads ordinary resource endpoints', () => { + service + .readReport('org', { + from: '2025-01-01', + to: '2025-01-31', + groupBy: 'customer', + page: 1, + itemsPerPage: 60, + }) + .subscribe(); + const request = http.expectOne((candidate) => candidate.url === endpoint + '/reports'); + expect(request.request.params.has('equipmentId')).toBe(false); + expect(request.request.params.has('siteId')).toBe(false); + expect(request.request.params.has('customerId')).toBe(false); + request.flush(maintenanceReportFixture({ groupBy: 'customer' })); + }); + it('loads a server-paginated minimal directory search with explicit optional scopes', () => { + service + .listDossiers('org', { + page: 3, + itemsPerPage: 30, + search: 'Entrance repair', + from: '2025-01-01', + to: '2025-01-31', + siteId: 'site', + }) + .subscribe(); + const request = http.expectOne((candidate) => candidate.url === endpoint + '/dossiers'); + expect(request.request.params.get('search')).toBe('Entrance repair'); + expect(request.request.params.get('page')).toBe('3'); + expect(request.request.params.get('siteId')).toBe('site'); + request.flush({ member: [], totalItems: 0 }); + }); + it('propagates oversized-scope rejection instead of returning a truncated total', () => { + let failure: unknown; + service + .readReport('org', { + from: '2025-01-01', + to: '2025-01-31', + groupBy: 'site', + page: 1, + itemsPerPage: 30, + }) + .subscribe({ + error: (error: unknown) => { + failure = error; + }, + }); + http + .expectOne((candidate) => candidate.url === endpoint + '/reports') + .flush( + { title: 'Narrow the scope', detail: 'The report covers too many dossiers.' }, + { status: 422, statusText: 'Unprocessable Entity' }, + ); + expect(failure).toMatchObject({ status: 422, detail: 'The report covers too many dossiers.' }); + }); +}); diff --git a/src/app/features/organization/features/maintenance-costs/maintenance-costs.routes.ts b/src/app/features/organization/features/maintenance-costs/maintenance-costs.routes.ts new file mode 100644 index 000000000..6639cebcb --- /dev/null +++ b/src/app/features/organization/features/maintenance-costs/maintenance-costs.routes.ts @@ -0,0 +1,45 @@ +import type { Routes } from '@angular/router'; +import { DASHBOARD_MOBILE_NAVIGATION_ROOT_DATA_KEY, type DashboardRouteData } from '@core/routing'; +import { organizationPermissionGuard } from '@features/organization/http/guards'; +import { ORGANIZATION_PERMISSION } from '@features/organization/models'; +import { unsavedChangesGuard } from '@shared/unsaved-changes'; + +/** + * Constant MAINTENANCE_COST_ROUTES + * + * @description + * A dedicated private financial workspace optionally scoped by the interventionId query parameter. + */ +export const MAINTENANCE_COST_ROUTES: Routes = [ + { + path: 'reports', + canActivate: [ + organizationPermissionGuard({ permissions: [ORGANIZATION_PERMISSION.MAINTENANCE_COST_READ] }), + ], + loadComponent: () => + import('./ui/pages/maintenance-reports-page/maintenance-reports-page.component').then( + (module) => module.MaintenanceReportsPage, + ), + title: $localize`:@@route.maintenanceReports:Economic pilotage`, + data: { + breadcrumb: $localize`:@@route.maintenanceReports:Economic pilotage`, + [DASHBOARD_MOBILE_NAVIGATION_ROOT_DATA_KEY]: true, + } satisfies DashboardRouteData, + }, + { + path: '', + canDeactivate: [unsavedChangesGuard], + canActivate: [ + organizationPermissionGuard({ permissions: [ORGANIZATION_PERMISSION.MAINTENANCE_COST_READ] }), + ], + loadComponent: () => + import('./ui/pages/maintenance-costs-page/maintenance-costs-page.component').then( + (module) => module.MaintenanceCostsPage, + ), + title: $localize`:@@route.maintenanceCosts:Maintenance costs`, + data: { + breadcrumb: $localize`:@@route.maintenanceCosts:Maintenance costs`, + [DASHBOARD_MOBILE_NAVIGATION_ROOT_DATA_KEY]: true, + } satisfies DashboardRouteData, + }, +]; diff --git a/src/app/features/organization/features/maintenance-costs/models/currency/maintenance-currency-output.interface.ts b/src/app/features/organization/features/maintenance-costs/models/currency/maintenance-currency-output.interface.ts new file mode 100644 index 000000000..3913a945b --- /dev/null +++ b/src/app/features/organization/features/maintenance-costs/models/currency/maintenance-currency-output.interface.ts @@ -0,0 +1,49 @@ +import type { HydraItem } from '@core/api/models'; + +/** + * Interface MaintenanceCurrencyOutput + * @interface MaintenanceCurrencyOutput + * + * @description + * The organization currency becomes immutable after its first financial fact. + */ +export interface MaintenanceCurrencyOutput extends HydraItem { + /** + * Property organizationId + * @readonly + * + * @description + * Owning organization UUID. + * + * @access public + * + * @type {string} + */ + readonly organizationId: string; + + /** + * Property currency + * @readonly + * + * @description + * Three-letter uppercase currency code. + * + * @access public + * + * @type {string} + */ + readonly currency: string; + + /** + * Property locked + * @readonly + * + * @description + * Whether operational financial facts have locked currency changes. + * + * @access public + * + * @type {boolean} + */ + readonly locked: boolean; +} diff --git a/src/app/features/organization/features/maintenance-costs/models/expense/create-maintenance-expense-input.interface.ts b/src/app/features/organization/features/maintenance-costs/models/expense/create-maintenance-expense-input.interface.ts new file mode 100644 index 000000000..f1aa65953 --- /dev/null +++ b/src/app/features/organization/features/maintenance-costs/models/expense/create-maintenance-expense-input.interface.ts @@ -0,0 +1,86 @@ +/** + * Interface CreateMaintenanceExpenseInput + * @interface CreateMaintenanceExpenseInput + * + * @description + * A stable expense declaration or motivated signed correction, preserved across retries. + */ +export interface CreateMaintenanceExpenseInput { + /** + * Property clientId + * @readonly + * + * @description + * Stable idempotency identifier generated once per declaration. + * + * @access public + * + * @type {string} + */ + readonly clientId: string; + + /** + * Property amount + * @readonly + * + * @description + * Exact signed decimal string with at most six fractional digits. + * + * @access public + * + * @type {string} + */ + readonly amount: string; + + /** + * Property description + * @readonly + * + * @description + * Required motivation for the operational expense or correction. + * + * @access public + * + * @type {string} + */ + readonly description: string; + + /** + * Property incurredAt + * @readonly + * + * @description + * Actual expense timestamp with an explicit offset. + * + * @access public + * + * @type {string} + */ + readonly incurredAt: string; + + /** + * Property workItemId + * @readonly + * + * @description + * Optional task associated with the expense. + * + * @access public + * + * @type {string | null} + */ + readonly workItemId?: string | null; + + /** + * Property adjustmentOf + * @readonly + * + * @description + * Original expense UUID required for a negative adjustment. + * + * @access public + * + * @type {string | null} + */ + readonly adjustmentOf?: string | null; +} diff --git a/src/app/features/organization/features/maintenance-costs/models/financial-directory/maintenance-financial-directory-query.interface.ts b/src/app/features/organization/features/maintenance-costs/models/financial-directory/maintenance-financial-directory-query.interface.ts new file mode 100644 index 000000000..12f6585ea --- /dev/null +++ b/src/app/features/organization/features/maintenance-costs/models/financial-directory/maintenance-financial-directory-query.interface.ts @@ -0,0 +1,113 @@ +/** + * Interface MaintenanceFinancialDirectoryQuery + * @interface MaintenanceFinancialDirectoryQuery + * + * @description + * Server-paginated minimal work search; selected identities narrow reports without ordinary read + * rights. + */ +export interface MaintenanceFinancialDirectoryQuery { + /** + * Property page + * @readonly + * + * @description + * One-based authorized directory page. + * + * @access public + * + * @type {number} + */ + readonly page: number; + + /** + * Property itemsPerPage + * @readonly + * + * @description + * Explicit bounded number of dossiers to load. + * + * @access public + * + * @type {number} + */ + readonly itemsPerPage: number; + + /** + * Property search + * @readonly + * + * @description + * Optional server search over human-readable reference and work name. + * + * @access public + * + * @type {string | undefined} + */ + readonly search?: string; + + /** + * Property from + * @readonly + * + * @description + * Optional inclusive publication start date. + * + * @access public + * + * @type {string | undefined} + */ + readonly from?: string; + + /** + * Property to + * @readonly + * + * @description + * Optional inclusive publication end date. + * + * @access public + * + * @type {string | undefined} + */ + readonly to?: string; + + /** + * Property siteId + * @readonly + * + * @description + * Optional server-owned site scope. + * + * @access public + * + * @type {string | undefined} + */ + readonly siteId?: string; + + /** + * Property customerId + * @readonly + * + * @description + * Optional internal customer scope. + * + * @access public + * + * @type {string | undefined} + */ + readonly customerId?: string; + + /** + * Property equipmentId + * @readonly + * + * @description + * Optional individual equipment scope. + * + * @access public + * + * @type {string | undefined} + */ + readonly equipmentId?: string; +} diff --git a/src/app/features/organization/features/maintenance-costs/models/financial-directory/maintenance-financial-dossier-output.interface.ts b/src/app/features/organization/features/maintenance-costs/models/financial-directory/maintenance-financial-dossier-output.interface.ts new file mode 100644 index 000000000..2a1307f45 --- /dev/null +++ b/src/app/features/organization/features/maintenance-costs/models/financial-directory/maintenance-financial-dossier-output.interface.ts @@ -0,0 +1,157 @@ +import type { HydraItem } from '@core/api/models'; +import type { + MaintenanceFinancialEquipmentIdentity, + MaintenanceFinancialIdentity, +} from './maintenance-financial-identity.interface'; + +/** + * Interface MaintenanceFinancialDossierOutput + * @interface MaintenanceFinancialDossierOutput + * + * @description + * Minimal paginated dossier directory available with dedicated financial read permission alone. + */ +export interface MaintenanceFinancialDossierOutput extends HydraItem { + /** + * Property id + * @readonly + * + * @description + * Scoped intervention identifier for the private financial dossier route. + * + * @access public + * + * @type {string} + */ + readonly id: string; + + /** + * Property number + * @readonly + * + * @description + * Human-readable work reference. + * + * @access public + * + * @type {string} + */ + readonly number: number; + + /** + * Property name + * @readonly + * + * @description + * Authorized minimal work label. + * + * @access public + * + * @type {string} + */ + readonly name: string; + + /** + * Property type + * @readonly + * + * @description + * Server-owned work type code retained in the transport contract. + * + * @access public + * + * @type {string} + */ + readonly type: string; + + /** + * Property status + * @readonly + * + * @description + * Server-owned work state retained without granting operational edit capabilities. + * + * @access public + * + * @type {string} + */ + readonly status: string; + + /** + * Property publishedAt + * @readonly + * + * @description + * Optional publication instant; historical missing snapshots remain distinct. + * + * @access public + * + * @type {string | null | undefined} + */ + readonly publishedAt?: string | null; + + /** + * Property site + * @readonly + * + * @description + * Optional minimum site identity captured or resolved by the owning server. + * + * @access public + * + * @type {MaintenanceFinancialIdentity | null | undefined} + */ + readonly site?: MaintenanceFinancialIdentity | null; + + /** + * Property customer + * @readonly + * + * @description + * Optional internal customer identity; an operator can have no customer record. + * + * @access public + * + * @type {MaintenanceFinancialIdentity | null | undefined} + */ + readonly customer?: MaintenanceFinancialIdentity | null; + + /** + * Property snapshotState + * @readonly + * + * @description + * Distinguishes immutable publication context, missing historical snapshot and live work. + * + * @access public + * + * @type {'available' | 'snapshot_missing' | 'live'} + */ + readonly snapshotState: 'available' | 'snapshot_missing' | 'live'; + + /** + * Property identityComplete + * @readonly + * + * @description + * Indicates whether the server could resolve the complete scoped identity. + * + * @access public + * + * @type {boolean} + */ + readonly identityComplete: boolean; + + /** + * Property equipment + * @readonly + * + * @description + * Minimal explicit equipment identities; whole-work costs are never duplicated over this list. + * + * @access public + * + * @type {readonly MaintenanceFinancialEquipmentIdentity[]} + */ + readonly equipment: readonly MaintenanceFinancialEquipmentIdentity[]; +} diff --git a/src/app/features/organization/features/maintenance-costs/models/financial-directory/maintenance-financial-identity.interface.ts b/src/app/features/organization/features/maintenance-costs/models/financial-directory/maintenance-financial-identity.interface.ts new file mode 100644 index 000000000..9d700f36c --- /dev/null +++ b/src/app/features/organization/features/maintenance-costs/models/financial-directory/maintenance-financial-identity.interface.ts @@ -0,0 +1,166 @@ +/** + * Interface MaintenanceFinancialIdentity + * @interface MaintenanceFinancialIdentity + * + * @description + * Minimal authorized financial scope identity without contacts or ordinary resource details. + */ +export interface MaintenanceFinancialIdentity { + /** + * Property id + * @readonly + * + * @description + * Organization-scoped stable identity used only in server queries. + * + * @access public + * + * @type {string} + */ + readonly id: string; + + /** + * Property name + * @readonly + * + * @description + * Minimal display name authorized by the financial directory. + * + * @access public + * + * @type {string} + */ + readonly name: string; +} + +/** + * Interface MaintenanceFinancialEquipmentIdentity + * @interface MaintenanceFinancialEquipmentIdentity + * + * @description + * Minimal equipment identity; missing historical names remain explicitly absent. + */ +export interface MaintenanceFinancialEquipmentIdentity { + /** + * Property site + * @readonly + * + * @description + * Equipment-specific site context; absence must not inherit another equipment's campaign site. + * + * @access public + * + * @type {MaintenanceFinancialIdentity | null | undefined} + */ + readonly site?: MaintenanceFinancialIdentity | null; + + /** + * Property customer + * @readonly + * + * @description + * Equipment-specific internal customer context, without contacts or cross-organization access. + * + * @access public + * + * @type {MaintenanceFinancialIdentity | null | undefined} + */ + readonly customer?: MaintenanceFinancialIdentity | null; + + /** + * Property id + * @readonly + * + * @description + * Stable organization-scoped equipment identifier. + * + * @access public + * + * @type {string} + */ + readonly id: string; + + /** + * Property name + * @readonly + * + * @description + * Optional historical or currently authorized equipment label. + * + * @access public + * + * @type {string | null | undefined} + */ + readonly name?: string | null; + + /** + * Property assetReference + * @readonly + * + * @description + * Optional patrimonial reference supplied by the financial directory. + * + * @access public + * + * @type {string | null | undefined} + */ + readonly assetReference?: string | null; +} + +/** + * Interface MaintenanceReportScopeSelection + * @interface MaintenanceReportScopeSelection + * + * @description + * Named scope retained by the report page without requesting ordinary resource directories. + */ +export interface MaintenanceReportScopeSelection { + /** + * Property site + * @readonly + * + * @description + * Optional minimal site selected from a server directory page. + * + * @access public + * + * @type {MaintenanceFinancialIdentity | undefined} + */ + readonly site?: MaintenanceFinancialIdentity; + + /** + * Property customer + * @readonly + * + * @description + * Optional internal customer identity, excluding contacts. + * + * @access public + * + * @type {MaintenanceFinancialIdentity | undefined} + */ + readonly customer?: MaintenanceFinancialIdentity; + + /** + * Property equipment + * @readonly + * + * @description + * Optional minimal individual equipment selected by the financial reader. + * + * @access public + * + * @type {MaintenanceFinancialEquipmentIdentity | undefined} + */ + readonly equipment?: MaintenanceFinancialEquipmentIdentity; +} + +/** + * Type MaintenanceReportScopeKind + * + * @description + * Scope selection to clear explicitly without resetting the report date draft. + * + * @type MaintenanceReportScopeKind + */ +export type MaintenanceReportScopeKind = 'site' | 'customer' | 'equipment' | 'all'; diff --git a/src/app/features/organization/features/maintenance-costs/models/index.ts b/src/app/features/organization/features/maintenance-costs/models/index.ts new file mode 100644 index 000000000..3992949c3 --- /dev/null +++ b/src/app/features/organization/features/maintenance-costs/models/index.ts @@ -0,0 +1,35 @@ +export type { MaintenanceEstimatedResource } from './maintenance-cost/maintenance-estimated-resource.interface'; +export type { MaintenanceCostItem } from './maintenance-cost/maintenance-cost-item.interface'; +export type { MaintenanceCostAllocation } from './maintenance-cost/maintenance-cost-allocation.interface'; +export type { MaintenanceCostProjection } from './maintenance-cost/maintenance-cost-projection.interface'; +export type { MaintenanceCostFrozen } from './maintenance-cost/maintenance-cost-frozen.interface'; +export type { MaintenanceCostOutput } from './maintenance-cost/maintenance-cost-output.interface'; +export type { MaintenanceCostCommand } from './maintenance-cost/maintenance-cost-command.type'; +export type { WriteMaintenanceCostPlanningInput } from './maintenance-cost/write-maintenance-cost-planning-input.interface'; +export type { CreateMaintenanceExpenseInput } from './expense/create-maintenance-expense-input.interface'; +export type { MaintenanceCurrencyOutput } from './currency/maintenance-currency-output.interface'; +export type { MaintenanceRateOutput } from './rate/maintenance-rate-output.interface'; +export type { CreateMaintenanceRateInput } from './rate/create-maintenance-rate-input.interface'; +export type { + MaintenanceReportQuery, + MaintenanceReportGroupBy, +} from './report/maintenance-report-query.interface'; +export type { MaintenanceFinancialDossierOutput } from './financial-directory/maintenance-financial-dossier-output.interface'; +export type { MaintenanceFinancialDirectoryQuery } from './financial-directory/maintenance-financial-directory-query.interface'; +export type { MaintenanceFinancialSourceDossier } from './report/maintenance-financial-source-dossier.interface'; +export type { MaintenanceEconomicAmount } from './report/maintenance-economic-amount.interface'; +export type { MaintenanceEconomicRow } from './report/maintenance-economic-row.interface'; +export type { + MaintenanceEconomicReportOutput, + MaintenanceEconomicReconciliation, +} from './report/maintenance-economic-report-output.interface'; +export type { + MaintenanceProcurementAmount, + MaintenanceProcurementOverview, +} from './report/maintenance-procurement-overview.interface'; +export type { + MaintenanceFinancialIdentity, + MaintenanceFinancialEquipmentIdentity, + MaintenanceReportScopeSelection, + MaintenanceReportScopeKind, +} from './financial-directory/maintenance-financial-identity.interface'; diff --git a/src/app/features/organization/features/maintenance-costs/models/maintenance-cost/maintenance-cost-allocation.interface.ts b/src/app/features/organization/features/maintenance-costs/models/maintenance-cost/maintenance-cost-allocation.interface.ts new file mode 100644 index 000000000..cb2b9a0d0 --- /dev/null +++ b/src/app/features/organization/features/maintenance-costs/models/maintenance-cost/maintenance-cost-allocation.interface.ts @@ -0,0 +1,71 @@ +import type { + MaintenanceFinancialEquipmentIdentity, + MaintenanceFinancialIdentity, +} from '../financial-directory/maintenance-financial-identity.interface'; + +/** + * Interface MaintenanceCostAllocation + * @interface MaintenanceCostAllocation + * + * @description + * Private source identity retained independently of live park names; historical absent fields + * remain explicit. + */ +export interface MaintenanceCostAllocation { + /** + * Property identityState + * @readonly + * + * @description + * Server provenance and completeness code for the contribution's allocation. + * + * @access public + * + * @type {string} + */ + readonly identityState: string; + + /** + * Property equipment + * @readonly + * + * @description + * Optional minimum equipment identity captured for this financial contribution. + * + * @access public + * + * @type {Pick + * | null + * | undefined} + */ + readonly equipment?: Pick< + MaintenanceFinancialEquipmentIdentity, + 'id' | 'name' | 'assetReference' + > | null; + + /** + * Property site + * @readonly + * + * @description + * Optional source site identity, without ordinary documents or additional contact data. + * + * @access public + * + * @type {MaintenanceFinancialIdentity | null | undefined} + */ + readonly site?: MaintenanceFinancialIdentity | null; + + /** + * Property customer + * @readonly + * + * @description + * Optional internal customer identity; operators and historical contributions may have none. + * + * @access public + * + * @type {MaintenanceFinancialIdentity | null | undefined} + */ + readonly customer?: MaintenanceFinancialIdentity | null; +} diff --git a/src/app/features/organization/features/maintenance-costs/models/maintenance-cost/maintenance-cost-command.type.ts b/src/app/features/organization/features/maintenance-costs/models/maintenance-cost/maintenance-cost-command.type.ts new file mode 100644 index 000000000..9628a1f02 --- /dev/null +++ b/src/app/features/organization/features/maintenance-costs/models/maintenance-cost/maintenance-cost-command.type.ts @@ -0,0 +1,35 @@ +import type { + CreateMaintenanceExpenseInput, + CreateMaintenanceRateInput, + WriteMaintenanceCostPlanningInput, +} from '@features/organization/features/maintenance-costs/models'; + +/** + * Type MaintenanceCostCommand + * + * @description + * One serialized financial write, retaining its original identity and displayed planning revision + * for retries. + * + * @type MaintenanceCostCommand + */ +export type MaintenanceCostCommand = + | { + readonly kind: 'planning'; + readonly organizationId: string; + readonly interventionId: string; + readonly revision: number; + readonly input: WriteMaintenanceCostPlanningInput; + } + | { + readonly kind: 'expense'; + readonly organizationId: string; + readonly interventionId: string; + readonly input: CreateMaintenanceExpenseInput; + } + | { readonly kind: 'currency'; readonly organizationId: string; readonly currency: string } + | { + readonly kind: 'rate'; + readonly organizationId: string; + readonly input: CreateMaintenanceRateInput; + }; diff --git a/src/app/features/organization/features/maintenance-costs/models/maintenance-cost/maintenance-cost-frozen.interface.ts b/src/app/features/organization/features/maintenance-costs/models/maintenance-cost/maintenance-cost-frozen.interface.ts new file mode 100644 index 000000000..09273e570 --- /dev/null +++ b/src/app/features/organization/features/maintenance-costs/models/maintenance-cost/maintenance-cost-frozen.interface.ts @@ -0,0 +1,115 @@ +import type { MaintenanceCostProjection } from './maintenance-cost-projection.interface'; +import type { MaintenanceEstimatedResource } from './maintenance-estimated-resource.interface'; + +/** + * Interface MaintenanceCostFrozen + * @interface MaintenanceCostFrozen + * + * @description + * The private closure snapshot remains unchanged by later operational corrections. + */ +export interface MaintenanceCostFrozen extends MaintenanceCostProjection { + /** + * Property version + * @readonly + * + * @description + * Snapshot schema version. + * + * @access public + * + * @type {1} + */ + readonly version: 1; + + /** + * Property capturedAt + * @readonly + * + * @description + * Closure capture timestamp. + * + * @access public + * + * @type {string} + */ + readonly capturedAt: string; + + /** + * Property publicationId + * @readonly + * + * @description + * Owning atomic publication identifier. + * + * @access public + * + * @type {string} + */ + readonly publicationId: string; + + /** + * Property interventionRevision + * @readonly + * + * @description + * Operational revision validated at publication. + * + * @access public + * + * @type {number} + */ + readonly interventionRevision: number; + + /** + * Property currency + * @readonly + * + * @description + * Currency retained at closure. + * + * @access public + * + * @type {string} + */ + readonly currency: string; + + /** + * Property plannedBudget + * @readonly + * + * @description + * Forecast retained at closure. + * + * @access public + * + * @type {string | null} + */ + readonly plannedBudget?: string | null; + + /** + * Property estimatedMinutes + * @readonly + * + * @description + * Estimated integral minutes retained at closure. + * + * @access public + * + * @type {number | null} + */ + readonly estimatedMinutes?: number | null; + + /** + * Property resources + * @readonly + * + * @description + * Forecast resources retained at closure. + * + * @access public + * + * @type {readonly MaintenanceEstimatedResource[]} + */ + readonly resources: readonly MaintenanceEstimatedResource[]; +} diff --git a/src/app/features/organization/features/maintenance-costs/models/maintenance-cost/maintenance-cost-item.interface.ts b/src/app/features/organization/features/maintenance-costs/models/maintenance-cost/maintenance-cost-item.interface.ts new file mode 100644 index 000000000..0fc1d4e96 --- /dev/null +++ b/src/app/features/organization/features/maintenance-costs/models/maintenance-cost/maintenance-cost-item.interface.ts @@ -0,0 +1,192 @@ +import type { MaintenanceCostAllocation } from './maintenance-cost-allocation.interface'; + +/** + * Interface MaintenanceCostItem + * @interface MaintenanceCostItem + * + * @description + * A private immutable contribution or compensating adjustment. + */ +export interface MaintenanceCostItem { + /** + * Property equipmentId + * @readonly + * + * @description + * Optional directly assigned equipment identifier, retained even without an operational task. + * + * @access public + * + * @type {string | null | undefined} + */ + readonly equipmentId?: string | null; + + /** + * Property allocation + * @readonly + * + * @description + * Optional private source identity; older immutable snapshots can omit this additive contract. + * + * @access public + * + * @type {MaintenanceCostAllocation | null | undefined} + */ + readonly allocation?: MaintenanceCostAllocation | null; + + /** + * Property id + * @readonly + * + * @description + * Stable contribution identifier. + * + * @access public + * + * @type {string} + */ + readonly id: string; + + /** + * Property kind + * @readonly + * + * @description + * Source category. + * + * @access public + * + * @type {'time' | 'material' | 'expense'} + */ + readonly kind: 'time' | 'material' | 'expense'; + + /** + * Property workItemId + * @readonly + * + * @description + * Associated work item when available. + * + * @access public + * + * @type {string | null} + */ + readonly workItemId?: string | null; + + /** + * Property sourceId + * @readonly + * + * @description + * Original time, stock or expense identifier. + * + * @access public + * + * @type {string} + */ + readonly sourceId: string; + + /** + * Property sourceRevision + * @readonly + * + * @description + * Source revision retained for traceability. + * + * @access public + * + * @type {number | null} + */ + readonly sourceRevision?: number | null; + + /** + * Property amount + * @readonly + * + * @description + * Exact signed decimal amount, or unknown. + * + * @access public + * + * @type {string | null} + */ + readonly amount?: string | null; + + /** + * Property currency + * @readonly + * + * @description + * Organization currency. + * + * @access public + * + * @type {string} + */ + readonly currency: string; + + /** + * Property description + * @readonly + * + * @description + * Operational source description. + * + * @access public + * + * @type {string} + */ + readonly description: string; + + /** + * Property occurredAt + * @readonly + * + * @description + * Original occurrence timestamp. + * + * @access public + * + * @type {string} + */ + readonly occurredAt: string; + + /** + * Property correctionOf + * @readonly + * + * @description + * Original contribution or closure reference corrected by this fact. + * + * @access public + * + * @type {string | null} + */ + readonly correctionOf?: string | null; + + /** + * Property hourlyAmount + * @readonly + * + * @description + * Exact hourly rate retained for a time contribution. + * + * @access public + * + * @type {string | null} + */ + readonly hourlyAmount?: string | null; + + /** + * Property rateId + * @readonly + * + * @description + * Applied immutable hourly rate identifier. + * + * @access public + * + * @type {string | null} + */ + readonly rateId?: string | null; +} diff --git a/src/app/features/organization/features/maintenance-costs/models/maintenance-cost/maintenance-cost-output.interface.ts b/src/app/features/organization/features/maintenance-costs/models/maintenance-cost/maintenance-cost-output.interface.ts new file mode 100644 index 000000000..ffc32f435 --- /dev/null +++ b/src/app/features/organization/features/maintenance-costs/models/maintenance-cost/maintenance-cost-output.interface.ts @@ -0,0 +1,156 @@ +import type { HydraItem } from '@core/api/models'; +import type { MaintenanceCostFrozen } from './maintenance-cost-frozen.interface'; +import type { MaintenanceCostProjection } from './maintenance-cost-projection.interface'; +import type { MaintenanceEstimatedResource } from './maintenance-estimated-resource.interface'; + +/** + * Interface MaintenanceCostOutput + * @interface MaintenanceCostOutput + * + * @description + * A dedicated private dossier separates current costs from its fixed closure snapshot. + */ +export interface MaintenanceCostOutput extends HydraItem { + /** + * Property id + * @readonly + * + * @description + * Intervention identity used by the cost resource. + * + * @access public + * + * @type {string} + */ + readonly id: string; + + /** + * Property organizationId + * @readonly + * + * @description + * Owning organization UUID. + * + * @access public + * + * @type {string} + */ + readonly organizationId: string; + + /** + * Property interventionId + * @readonly + * + * @description + * Owning intervention UUID. + * + * @access public + * + * @type {string} + */ + readonly interventionId: string; + + /** + * Property currency + * @readonly + * + * @description + * Organization currency. + * + * @access public + * + * @type {string} + */ + readonly currency: string; + + /** + * Property plannedBudget + * @readonly + * + * @description + * Explicit exact forecast, or unknown. + * + * @access public + * + * @type {string | null} + */ + readonly plannedBudget?: string | null; + + /** + * Property estimatedMinutes + * @readonly + * + * @description + * Explicit integral effort estimate, or unknown. + * + * @access public + * + * @type {number | null} + */ + readonly estimatedMinutes?: number | null; + + /** + * Property planningRevision + * @readonly + * + * @description + * Independently versioned forecast; zero before its first save. + * + * @access public + * + * @type {number} + */ + readonly planningRevision: number; + + /** + * Property planningEditable + * @readonly + * + * @description + * Server capability for forecast edits. + * + * @access public + * + * @type {boolean} + */ + readonly planningEditable: boolean; + + /** + * Property resources + * @readonly + * + * @description + * Current estimated resources. + * + * @access public + * + * @type {readonly MaintenanceEstimatedResource[]} + */ + readonly resources: readonly MaintenanceEstimatedResource[]; + + /** + * Property current + * @readonly + * + * @description + * Current realized operational costs. + * + * @access public + * + * @type {MaintenanceCostProjection} + */ + readonly current: MaintenanceCostProjection; + + /** + * Property frozen + * @readonly + * + * @description + * Private closure snapshot, if the intervention was published. + * + * @access public + * + * @type {MaintenanceCostFrozen | null} + */ + readonly frozen?: MaintenanceCostFrozen | null; +} diff --git a/src/app/features/organization/features/maintenance-costs/models/maintenance-cost/maintenance-cost-projection.interface.ts b/src/app/features/organization/features/maintenance-costs/models/maintenance-cost/maintenance-cost-projection.interface.ts new file mode 100644 index 000000000..55430dca4 --- /dev/null +++ b/src/app/features/organization/features/maintenance-costs/models/maintenance-cost/maintenance-cost-projection.interface.ts @@ -0,0 +1,62 @@ +import type { MaintenanceCostItem } from './maintenance-cost-item.interface'; + +/** + * Interface MaintenanceCostProjection + * @interface MaintenanceCostProjection + * + * @description + * A server-calculated total keeps incomplete valuation distinct from known zero. + */ +export interface MaintenanceCostProjection { + /** + * Property total + * @readonly + * + * @description + * Complete exact total, absent when any contribution is unknown. + * + * @access public + * + * @type {string | null} + */ + readonly total?: string | null; + + /** + * Property knownTotal + * @readonly + * + * @description + * Exact sum of the known contributions. + * + * @access public + * + * @type {string} + */ + readonly knownTotal: string; + + /** + * Property complete + * @readonly + * + * @description + * Whether every contribution has a known valuation. + * + * @access public + * + * @type {boolean} + */ + readonly complete: boolean; + + /** + * Property items + * @readonly + * + * @description + * Immutable current contribution rows. + * + * @access public + * + * @type {readonly MaintenanceCostItem[]} + */ + readonly items: readonly MaintenanceCostItem[]; +} diff --git a/src/app/features/organization/features/maintenance-costs/models/maintenance-cost/maintenance-estimated-resource.interface.ts b/src/app/features/organization/features/maintenance-costs/models/maintenance-cost/maintenance-estimated-resource.interface.ts new file mode 100644 index 000000000..9ad7e4f2b --- /dev/null +++ b/src/app/features/organization/features/maintenance-costs/models/maintenance-cost/maintenance-estimated-resource.interface.ts @@ -0,0 +1,99 @@ +/** + * Interface MaintenanceEstimatedResource + * @interface MaintenanceEstimatedResource + * + * @description + * An explicit forecast resource; missing amounts remain unknown. + */ +export interface MaintenanceEstimatedResource { + /** + * Property workItemId + * @readonly + * + * @description + * Optional operational task reference. + * + * @access public + * + * @type {string | null} + */ + readonly workItemId?: string | null; + + /** + * Property kind + * @readonly + * + * @description + * Resource category defining server valuation. + * + * @access public + * + * @type {'time' | 'material' | 'external'} + */ + readonly kind: 'time' | 'material' | 'external'; + + /** + * Property description + * @readonly + * + * @description + * Human-readable forecast purpose. + * + * @access public + * + * @type {string} + */ + readonly description: string; + + /** + * Property quantity + * @readonly + * + * @description + * Exact decimal quantity. + * + * @access public + * + * @type {string | null} + */ + readonly quantity?: string | null; + + /** + * Property unitCost + * @readonly + * + * @description + * Exact unit cost or hourly cost for time. + * + * @access public + * + * @type {string | null} + */ + readonly unitCost?: string | null; + + /** + * Property estimatedMinutes + * @readonly + * + * @description + * Estimated integral time in minutes. + * + * @access public + * + * @type {number | null} + */ + readonly estimatedMinutes?: number | null; + + /** + * Property amount + * @readonly + * + * @description + * Explicit or server-derived exact estimated amount. + * + * @access public + * + * @type {string | null} + */ + readonly amount?: string | null; +} diff --git a/src/app/features/organization/features/maintenance-costs/models/maintenance-cost/testing/maintenance-cost.fixture.ts b/src/app/features/organization/features/maintenance-costs/models/maintenance-cost/testing/maintenance-cost.fixture.ts new file mode 100644 index 000000000..7daa55646 --- /dev/null +++ b/src/app/features/organization/features/maintenance-costs/models/maintenance-cost/testing/maintenance-cost.fixture.ts @@ -0,0 +1,32 @@ +import type { MaintenanceCostOutput } from '../maintenance-cost-output.interface'; + +/** + * Function maintenanceCostFixture + * + * @description + * Private financial fixture keeps exact unknown/current/frozen distinctions. + * + * @param {Partial} overrides - Explicit fixture contract overrides. + * + * @returns {MaintenanceCostOutput} Validated financial contract result. + */ +export function maintenanceCostFixture( + overrides: Partial = {}, +): MaintenanceCostOutput { + return { + '@id': '/api/organizations/org/interventions/12345678-1234-4234-8234-123456789abc/costs', + '@type': 'MaintenanceCost', + id: '12345678-1234-4234-8234-123456789abc', + organizationId: 'org', + interventionId: '12345678-1234-4234-8234-123456789abc', + currency: 'EUR', + plannedBudget: null, + estimatedMinutes: null, + planningRevision: 0, + planningEditable: true, + resources: [], + current: { total: null, knownTotal: '0.000000', complete: false, items: [] }, + frozen: null, + ...overrides, + }; +} diff --git a/src/app/features/organization/features/maintenance-costs/models/maintenance-cost/write-maintenance-cost-planning-input.interface.ts b/src/app/features/organization/features/maintenance-costs/models/maintenance-cost/write-maintenance-cost-planning-input.interface.ts new file mode 100644 index 000000000..68b696c79 --- /dev/null +++ b/src/app/features/organization/features/maintenance-costs/models/maintenance-cost/write-maintenance-cost-planning-input.interface.ts @@ -0,0 +1,49 @@ +import type { MaintenanceEstimatedResource } from './maintenance-estimated-resource.interface'; + +/** + * Interface WriteMaintenanceCostPlanningInput + * @interface WriteMaintenanceCostPlanningInput + * + * @description + * Only explicitly submitted forecast fields change, including an explicit unknown value. + */ +export interface WriteMaintenanceCostPlanningInput { + /** + * Property plannedBudget + * @readonly + * + * @description + * Exact nonnegative budget or explicit unknown. + * + * @access public + * + * @type {string | null} + */ + readonly plannedBudget?: string | null; + + /** + * Property estimatedMinutes + * @readonly + * + * @description + * Integral estimated minutes or explicit unknown. + * + * @access public + * + * @type {number | null} + */ + readonly estimatedMinutes?: number | null; + + /** + * Property resources + * @readonly + * + * @description + * Explicit replacement forecast resources. + * + * @access public + * + * @type {readonly MaintenanceEstimatedResource[]} + */ + readonly resources?: readonly MaintenanceEstimatedResource[]; +} diff --git a/src/app/features/organization/features/maintenance-costs/models/rate/create-maintenance-rate-input.interface.ts b/src/app/features/organization/features/maintenance-costs/models/rate/create-maintenance-rate-input.interface.ts new file mode 100644 index 000000000..d43d5f09b --- /dev/null +++ b/src/app/features/organization/features/maintenance-costs/models/rate/create-maintenance-rate-input.interface.ts @@ -0,0 +1,60 @@ +/** + * Interface CreateMaintenanceRateInput + * @interface CreateMaintenanceRateInput + * + * @description + * Rate identity and payload are retained together after any uncertain write. + */ +export interface CreateMaintenanceRateInput { + /** + * Property clientId + * @readonly + * + * @description + * Stable client operation UUID. + * + * @access public + * + * @type {string} + */ + readonly clientId: string; + + /** + * Property memberId + * @readonly + * + * @description + * Organization member UUID. + * + * @access public + * + * @type {string} + */ + readonly memberId: string; + + /** + * Property hourlyAmount + * @readonly + * + * @description + * Exact nonnegative decimal hourly amount. + * + * @access public + * + * @type {string} + */ + readonly hourlyAmount: string; + + /** + * Property effectiveFrom + * @readonly + * + * @description + * Explicit effective local calendar date. + * + * @access public + * + * @type {string} + */ + readonly effectiveFrom: string; +} diff --git a/src/app/features/organization/features/maintenance-costs/models/rate/maintenance-rate-output.interface.ts b/src/app/features/organization/features/maintenance-costs/models/rate/maintenance-rate-output.interface.ts new file mode 100644 index 000000000..fb4b96d7f --- /dev/null +++ b/src/app/features/organization/features/maintenance-costs/models/rate/maintenance-rate-output.interface.ts @@ -0,0 +1,88 @@ +import type { HydraItem } from '@core/api/models'; + +/** + * Interface MaintenanceRateOutput + * @interface MaintenanceRateOutput + * + * @description + * An immutable member hourly rate effective from an explicit local calendar date. + */ +export interface MaintenanceRateOutput extends HydraItem { + /** + * Property id + * @readonly + * + * @description + * Stable rate UUID. + * + * @access public + * + * @type {string} + */ + readonly id: string; + + /** + * Property memberId + * @readonly + * + * @description + * Organization member UUID. + * + * @access public + * + * @type {string} + */ + readonly memberId: string; + + /** + * Property hourlyAmount + * @readonly + * + * @description + * Exact nonnegative hourly amount. + * + * @access public + * + * @type {string} + */ + readonly hourlyAmount: string; + + /** + * Property currency + * @readonly + * + * @description + * Common organization currency. + * + * @access public + * + * @type {string} + */ + readonly currency: string; + + /** + * Property effectiveFrom + * @readonly + * + * @description + * Effective calendar date in YYYY-MM-DD format. + * + * @access public + * + * @type {string} + */ + readonly effectiveFrom: string; + + /** + * Property replayed + * @readonly + * + * @description + * Whether creation replayed the same stable declaration. + * + * @access public + * + * @type {boolean} + */ + readonly replayed: boolean; +} diff --git a/src/app/features/organization/features/maintenance-costs/models/report/maintenance-economic-amount.interface.ts b/src/app/features/organization/features/maintenance-costs/models/report/maintenance-economic-amount.interface.ts new file mode 100644 index 000000000..f68b26948 --- /dev/null +++ b/src/app/features/organization/features/maintenance-costs/models/report/maintenance-economic-amount.interface.ts @@ -0,0 +1,73 @@ +/** + * Interface MaintenanceEconomicAmount + * @interface MaintenanceEconomicAmount + * + * @description + * Exact complete total and separately retained known subtotal; unknown facts never become zero. + */ +export interface MaintenanceEconomicAmount { + /** + * Property total + * @readonly + * + * @description + * Complete six-place total, omitted when one or more facts lack valuation. + * + * @access public + * + * @type {string | null | undefined} + */ + readonly total?: string | null; + + /** + * Property knownTotal + * @readonly + * + * @description + * Exact known subtotal across the full filtered scope, independent of pagination. + * + * @access public + * + * @type {string} + */ + readonly knownTotal: string; + + /** + * Property complete + * @readonly + * + * @description + * Whether every retained contribution has a valuation. + * + * @access public + * + * @type {boolean} + */ + readonly complete: boolean; + + /** + * Property contributionCount + * @readonly + * + * @description + * Number of retained source contributions, including unknown values. + * + * @access public + * + * @type {number} + */ + readonly contributionCount: number; + + /** + * Property unknownCount + * @readonly + * + * @description + * Number of source contributions whose valuation is unknown. + * + * @access public + * + * @type {number} + */ + readonly unknownCount: number; +} diff --git a/src/app/features/organization/features/maintenance-costs/models/report/maintenance-economic-report-output.interface.ts b/src/app/features/organization/features/maintenance-costs/models/report/maintenance-economic-report-output.interface.ts new file mode 100644 index 000000000..6ba78c6b0 --- /dev/null +++ b/src/app/features/organization/features/maintenance-costs/models/report/maintenance-economic-report-output.interface.ts @@ -0,0 +1,415 @@ +import type { HydraItem } from '@core/api/models'; +import type { MaintenanceEconomicAmount } from './maintenance-economic-amount.interface'; +import type { MaintenanceEconomicRow } from './maintenance-economic-row.interface'; +import type { MaintenanceFinancialSourceDossier } from './maintenance-financial-source-dossier.interface'; +import type { MaintenanceProcurementOverview } from './maintenance-procurement-overview.interface'; + +/** + * Interface MaintenanceEconomicReconciliation + * @interface MaintenanceEconomicReconciliation + * + * @description + * Source dossier totals reconcile selected allocations plus explicitly excluded targets. + */ +export interface MaintenanceEconomicReconciliation { + /** + * Property sourceCurrent + * @readonly + * + * @description + * Full source realized amount before equipment allocation filtering. + * + * @access public + * + * @type {MaintenanceEconomicAmount} + */ + readonly sourceCurrent: MaintenanceEconomicAmount; + + /** + * Property excludedCurrent + * @readonly + * + * @description + * Current facts outside the selected allocation target. + * + * @access public + * + * @type {MaintenanceEconomicAmount} + */ + readonly excludedCurrent: MaintenanceEconomicAmount; + + /** + * Property sourceFrozen + * @readonly + * + * @description + * Full source immutable closure amount. + * + * @access public + * + * @type {MaintenanceEconomicAmount} + */ + readonly sourceFrozen: MaintenanceEconomicAmount; + + /** + * Property excludedFrozen + * @readonly + * + * @description + * Frozen facts outside the selected allocation target. + * + * @access public + * + * @type {MaintenanceEconomicAmount} + */ + readonly excludedFrozen: MaintenanceEconomicAmount; + + /** + * Property sourcePlanned + * @readonly + * + * @description + * Full source resource estimates. + * + * @access public + * + * @type {MaintenanceEconomicAmount} + */ + readonly sourcePlanned: MaintenanceEconomicAmount; + + /** + * Property excludedPlanned + * @readonly + * + * @description + * Estimated facts outside the selected allocation target. + * + * @access public + * + * @type {MaintenanceEconomicAmount} + */ + readonly excludedPlanned: MaintenanceEconomicAmount; + + /** + * Property reconciled + * @readonly + * + * @description + * Server assertion that selected and excluded allocations preserve all source facts. + * + * @access public + * + * @type {boolean} + */ + readonly reconciled: boolean; +} + +/** + * Interface MaintenanceEconomicReportOutput + * @interface MaintenanceEconomicReportOutput + * + * @description + * Server-calculated bounded financial report; totals cover all filtered results, never only the + * visible page. + */ +export interface MaintenanceEconomicReportOutput extends HydraItem { + /** + * Property id + * @readonly + * + * @description + * Stable organization identity for the aggregate resource. + * + * @access public + * + * @type {string} + */ + readonly id: string; + + /** + * Property dossiers + * @readonly + * + * @description + * Complete bounded source names; a row uses its exact interventionIds to select these sources. + * + * @access public + * + * @type {readonly MaintenanceFinancialSourceDossier[]} + */ + readonly dossiers: readonly MaintenanceFinancialSourceDossier[]; + + /** + * Property organizationId + * @readonly + * + * @description + * Authorized organization identity used by the session fence. + * + * @access public + * + * @type {string} + */ + readonly organizationId: string; + + /** + * Property from + * @readonly + * + * @description + * Inclusive report first civil date. + * + * @access public + * + * @type {string} + */ + readonly from: string; + + /** + * Property to + * @readonly + * + * @description + * Inclusive report last civil date. + * + * @access public + * + * @type {string} + */ + readonly to: string; + + /** + * Property groupBy + * @readonly + * + * @description + * Server-owned allocation dimension. + * + * @access public + * + * @type {'equipment' | 'site' | 'customer'} + */ + readonly groupBy: 'equipment' | 'site' | 'customer'; + + /** + * Property currency + * @readonly + * + * @description + * Single organization currency for every reported amount. + * + * @access public + * + * @type {string} + */ + readonly currency: string; + + /** + * Property page + * @readonly + * + * @description + * Actual one-based result page. + * + * @access public + * + * @type {number} + */ + readonly page: number; + + /** + * Property itemsPerPage + * @readonly + * + * @description + * Actual server result page size. + * + * @access public + * + * @type {number} + */ + readonly itemsPerPage: number; + + /** + * Property totalItems + * @readonly + * + * @description + * Total number of allocated destinations across all pages. + * + * @access public + * + * @type {number} + */ + readonly totalItems: number; + + /** + * Property interventionCount + * @readonly + * + * @description + * Number of selected source work dossiers. + * + * @access public + * + * @type {number} + */ + readonly interventionCount: number; + + /** + * Property publishedInterventionCount + * @readonly + * + * @description + * Source work dossiers that have been published. + * + * @access public + * + * @type {number} + */ + readonly publishedInterventionCount: number; + + /** + * Property liveInterventionCount + * @readonly + * + * @description + * Source work dossiers that remain live. + * + * @access public + * + * @type {number} + */ + readonly liveInterventionCount: number; + + /** + * Property missingSnapshotCount + * @readonly + * + * @description + * Historical publications with no immutable financial snapshot. + * + * @access public + * + * @type {number} + */ + readonly missingSnapshotCount: number; + + /** + * Property rows + * @readonly + * + * @description + * Visible allocated destinations only; their amounts are not the overall total. + * + * @access public + * + * @type {readonly MaintenanceEconomicRow[]} + */ + readonly rows: readonly MaintenanceEconomicRow[]; + + /** + * Property unallocated + * @readonly + * + * @description + * Distinct unresolved allocation facts, displayed separately from destination pages. + * + * @access public + * + * @type {MaintenanceEconomicRow} + */ + readonly unallocated: MaintenanceEconomicRow; + + /** + * Property current + * @readonly + * + * @description + * Full filtered realized amount, including explicit unallocated contributions. + * + * @access public + * + * @type {MaintenanceEconomicAmount} + */ + readonly current: MaintenanceEconomicAmount; + + /** + * Property frozen + * @readonly + * + * @description + * Full filtered immutable closure amount. + * + * @access public + * + * @type {MaintenanceEconomicAmount} + */ + readonly frozen: MaintenanceEconomicAmount; + + /** + * Property planned + * @readonly + * + * @description + * Full filtered resource estimates, using global dossier budgets only when no resources are + * estimated. + * + * @access public + * + * @type {MaintenanceEconomicAmount} + */ + readonly planned: MaintenanceEconomicAmount; + + /** + * Property budget + * @readonly + * + * @description + * Full filtered budgets, independent of resource estimates. + * + * @access public + * + * @type {MaintenanceEconomicAmount} + */ + readonly budget: MaintenanceEconomicAmount; + + /** + * Property variance + * @readonly + * + * @description + * Exact current-minus-resource-estimate difference, absent if incomplete. + * + * @access public + * + * @type {string | null | undefined} + */ + readonly variance?: string | null; + + /** + * Property reconciliation + * @readonly + * + * @description + * Original source versus selected and excluded allocation totals. + * + * @access public + * + * @type {MaintenanceEconomicReconciliation} + */ + readonly reconciliation: MaintenanceEconomicReconciliation; + + /** + * Property procurement + * @readonly + * + * @description + * Organization order-creation-window overview with separate receipt history semantics. + * + * @access public + * + * @type {MaintenanceProcurementOverview} + */ + readonly procurement: MaintenanceProcurementOverview; +} diff --git a/src/app/features/organization/features/maintenance-costs/models/report/maintenance-economic-row.interface.ts b/src/app/features/organization/features/maintenance-costs/models/report/maintenance-economic-row.interface.ts new file mode 100644 index 000000000..4a681d0f5 --- /dev/null +++ b/src/app/features/organization/features/maintenance-costs/models/report/maintenance-economic-row.interface.ts @@ -0,0 +1,140 @@ +import type { MaintenanceEconomicAmount } from './maintenance-economic-amount.interface'; + +/** + * Interface MaintenanceEconomicRow + * @interface MaintenanceEconomicRow + * + * @description + * One allocation destination; unallocated costs are explicit and each fact is counted once. + */ +export interface MaintenanceEconomicRow { + /** + * Property id + * @readonly + * + * @description + * Stable allocation identity, absent for the separate unallocated row. + * + * @access public + * + * @type {string | null | undefined} + */ + readonly id?: string | null; + + /** + * Property name + * @readonly + * + * @description + * Authorized minimal source label; absent historical identities remain explicit. + * + * @access public + * + * @type {string | null | undefined} + */ + readonly name?: string | null; + + /** + * Property identityState + * @readonly + * + * @description + * Server identity availability code retained for presentation normalization. + * + * @access public + * + * @type {string} + */ + readonly identityState: 'captured' | 'live' | 'mixed' | 'incomplete' | 'unallocated'; + + /** + * Property allocationComplete + * @readonly + * + * @description + * Whether every retained fact has a resolved allocation identity. + * + * @access public + * + * @type {boolean} + */ + readonly allocationComplete: boolean; + + /** + * Property current + * @readonly + * + * @description + * Current realized facts, including late corrections. + * + * @access public + * + * @type {MaintenanceEconomicAmount} + */ + readonly current: MaintenanceEconomicAmount; + + /** + * Property frozen + * @readonly + * + * @description + * Original immutable closure contributions. + * + * @access public + * + * @type {MaintenanceEconomicAmount} + */ + readonly frozen: MaintenanceEconomicAmount; + + /** + * Property planned + * @readonly + * + * @description + * Resource estimates, or the overall budget when the source dossier has no resource estimate. + * + * @access public + * + * @type {MaintenanceEconomicAmount} + */ + readonly planned: MaintenanceEconomicAmount; + + /** + * Property budget + * @readonly + * + * @description + * Global budgets allocated only once and never added to resource estimates. + * + * @access public + * + * @type {MaintenanceEconomicAmount} + */ + readonly budget: MaintenanceEconomicAmount; + + /** + * Property variance + * @readonly + * + * @description + * Exact current-minus-planned difference, omitted when incomplete. + * + * @access public + * + * @type {string | null | undefined} + */ + readonly variance?: string | null; + + /** + * Property interventionIds + * @readonly + * + * @description + * Distinct authorized source work identifiers; the directory supplies their minimal names. + * + * @access public + * + * @type {readonly string[]} + */ + readonly interventionIds: readonly string[]; +} diff --git a/src/app/features/organization/features/maintenance-costs/models/report/maintenance-financial-source-dossier.interface.ts b/src/app/features/organization/features/maintenance-costs/models/report/maintenance-financial-source-dossier.interface.ts new file mode 100644 index 000000000..29b68025a --- /dev/null +++ b/src/app/features/organization/features/maintenance-costs/models/report/maintenance-financial-source-dossier.interface.ts @@ -0,0 +1,74 @@ +/** + * Interface MaintenanceFinancialSourceDossier + * @interface MaintenanceFinancialSourceDossier + * + * @description + * Bounded report source names authorize exact navigation from an allocation row without extra + * reads. + */ +export interface MaintenanceFinancialSourceDossier { + /** + * Property id + * @readonly + * + * @description + * Stable private financial dossier identifier. + * + * @access public + * + * @type {string} + */ + readonly id: string; + + /** + * Property number + * @readonly + * + * @description + * Human-readable work reference. + * + * @access public + * + * @type {string} + */ + readonly number: number; + + /** + * Property name + * @readonly + * + * @description + * Minimal authorized source label. + * + * @access public + * + * @type {string} + */ + readonly name: string; + + /** + * Property status + * @readonly + * + * @description + * Server-owned source state, without operational edit capabilities. + * + * @access public + * + * @type {string} + */ + readonly status: string; + + /** + * Property snapshotState + * @readonly + * + * @description + * Immutable, live or historical-missing source context. + * + * @access public + * + * @type {'available' | 'snapshot_missing' | 'live'} + */ + readonly snapshotState: 'available' | 'snapshot_missing' | 'live'; +} diff --git a/src/app/features/organization/features/maintenance-costs/models/report/maintenance-procurement-overview.interface.ts b/src/app/features/organization/features/maintenance-costs/models/report/maintenance-procurement-overview.interface.ts new file mode 100644 index 000000000..10f6c73f1 --- /dev/null +++ b/src/app/features/organization/features/maintenance-costs/models/report/maintenance-procurement-overview.interface.ts @@ -0,0 +1,225 @@ +/** + * Interface MaintenanceProcurementAmount + * @interface MaintenanceProcurementAmount + * + * @description + * Exact purchase amount with an explicit incomplete known subtotal. + */ +export interface MaintenanceProcurementAmount { + /** + * Property total + * @readonly + * + * @description + * Complete amount, omitted if positive quantities have unknown prices. + * + * @access public + * + * @type {string | null | undefined} + */ + readonly total?: string | null; + + /** + * Property knownTotal + * @readonly + * + * @description + * Exact subtotal of quantities with known purchase prices. + * + * @access public + * + * @type {string} + */ + readonly knownTotal: string; + + /** + * Property complete + * @readonly + * + * @description + * Whether every selected positive quantity has a known purchase price. + * + * @access public + * + * @type {boolean} + */ + readonly complete: boolean; +} + +/** + * Interface MaintenanceProcurementOverview + * @interface MaintenanceProcurementOverview + * + * @description + * Organization procurement commitments for orders created during the report window. + */ +export interface MaintenanceProcurementOverview { + /** + * Property organizationId + * @readonly + * + * @description + * Authorized owning organization identifier. + * + * @access public + * + * @type {string} + */ + readonly organizationId: string; + + /** + * Property currency + * @readonly + * + * @description + * Single organization currency. + * + * @access public + * + * @type {string} + */ + readonly currency: string; + + /** + * Property from + * @readonly + * + * @description + * Inclusive order-creation start instant. + * + * @access public + * + * @type {string} + */ + readonly from: string; + + /** + * Property to + * @readonly + * + * @description + * Exclusive order-creation end instant. + * + * @access public + * + * @type {string} + */ + readonly to: string; + + /** + * Property orderCount + * @readonly + * + * @description + * Selected non-draft orders including cancelled orders with retained deliveries. + * + * @access public + * + * @type {number} + */ + readonly orderCount: number; + + /** + * Property receiptCount + * @readonly + * + * @description + * Physical receipts over selected orders' entire history. + * + * @access public + * + * @type {number} + */ + readonly receiptCount: number; + + /** + * Property pendingIndividualizationCount + * @readonly + * + * @description + * Equipment receipts still awaiting individualization in the park. + * + * @access public + * + * @type {number} + */ + readonly pendingIndividualizationCount: number; + + /** + * Property ordered + * @readonly + * + * @description + * Effective commitments excluding cancelled remainder. + * + * @access public + * + * @type {MaintenanceProcurementAmount} + */ + readonly ordered: MaintenanceProcurementAmount; + + /** + * Property received + * @readonly + * + * @description + * Gross delivered value before supplier returns. + * + * @access public + * + * @type {MaintenanceProcurementAmount} + */ + readonly received: MaintenanceProcurementAmount; + + /** + * Property outstanding + * @readonly + * + * @description + * Remaining quantity receivable; supplier returns do not reopen this amount. + * + * @access public + * + * @type {MaintenanceProcurementAmount} + */ + readonly outstanding: MaintenanceProcurementAmount; + + /** + * Property returned + * @readonly + * + * @description + * Declared supplier returns including pending stock reconciliation. + * + * @access public + * + * @type {MaintenanceProcurementAmount} + */ + readonly returned: MaintenanceProcurementAmount; + + /** + * Property basis + * @readonly + * + * @description + * The order creation window governs procurement selection. + * + * @access public + * + * @type {'order_created_at'} + */ + readonly basis: 'order_created_at'; + + /** + * Property receiptScope + * @readonly + * + * @description + * Receipts and returns include selected orders' complete histories. + * + * @access public + * + * @type {'selected_orders_all_history'} + */ + readonly receiptScope: 'selected_orders_all_history'; +} diff --git a/src/app/features/organization/features/maintenance-costs/models/report/maintenance-report-query.interface.ts b/src/app/features/organization/features/maintenance-costs/models/report/maintenance-report-query.interface.ts new file mode 100644 index 000000000..f21e11bfe --- /dev/null +++ b/src/app/features/organization/features/maintenance-costs/models/report/maintenance-report-query.interface.ts @@ -0,0 +1,122 @@ +/** + * Type MaintenanceReportGroupBy + * + * @description + * Server-owned financial allocation dimension; each contribution is counted once. + * + * @type MaintenanceReportGroupBy + */ +export type MaintenanceReportGroupBy = 'equipment' | 'site' | 'customer'; + +/** + * Interface MaintenanceReportQuery + * @interface MaintenanceReportQuery + * + * @description + * Inclusive civil-date window and explicit organization-owned filters for one report page. + */ +export interface MaintenanceReportQuery { + /** + * Property from + * @readonly + * + * @description + * Inclusive first civil date in YYYY-MM-DD format. + * + * @access public + * + * @type {string} + */ + readonly from: string; + + /** + * Property to + * @readonly + * + * @description + * Inclusive last civil date, at most 366 days after the first date inclusive. + * + * @access public + * + * @type {string} + */ + readonly to: string; + + /** + * Property groupBy + * @readonly + * + * @description + * Grouping dimension authorized and calculated by the server. + * + * @access public + * + * @type {MaintenanceReportGroupBy} + */ + readonly groupBy: MaintenanceReportGroupBy; + + /** + * Property siteId + * @readonly + * + * @description + * Optional named site selected from the private financial directory. + * + * @access public + * + * @type {string | undefined} + */ + readonly siteId?: string; + + /** + * Property customerId + * @readonly + * + * @description + * Optional named internal customer selected from the private financial directory. + * + * @access public + * + * @type {string | undefined} + */ + readonly customerId?: string; + + /** + * Property equipmentId + * @readonly + * + * @description + * Optional individual equipment selected from the private financial directory. + * + * @access public + * + * @type {string | undefined} + */ + readonly equipmentId?: string; + + /** + * Property page + * @readonly + * + * @description + * One-based server result page. + * + * @access public + * + * @type {number} + */ + readonly page: number; + + /** + * Property itemsPerPage + * @readonly + * + * @description + * Requested bounded page size, independent of filtered report totals. + * + * @access public + * + * @type {number} + */ + readonly itemsPerPage: number; +} diff --git a/src/app/features/organization/features/maintenance-costs/models/report/testing/maintenance-report.fixture.ts b/src/app/features/organization/features/maintenance-costs/models/report/testing/maintenance-report.fixture.ts new file mode 100644 index 000000000..973b37ec4 --- /dev/null +++ b/src/app/features/organization/features/maintenance-costs/models/report/testing/maintenance-report.fixture.ts @@ -0,0 +1,206 @@ +import type { MaintenanceEconomicAmount } from '../maintenance-economic-amount.interface'; +import type { MaintenanceEconomicReportOutput } from '../maintenance-economic-report-output.interface'; +import type { MaintenanceEconomicRow } from '../maintenance-economic-row.interface'; + +/** + * Constant REPORT_INTERVENTION_ID + * + * @description + * Stable source dossier shared by the allocated and unallocated financial fixture contributions. + * + * @access public + * @since unreleased + */ +export const REPORT_INTERVENTION_ID = '810e8400-e29b-41d4-a716-446655810001'; + +/** + * Constant REPORT_OTHER_INTERVENTION_ID + * + * @description + * Independent dossier identity used to verify exact source selection. + * + * @access public + * @since unreleased + */ +export const REPORT_OTHER_INTERVENTION_ID = '810e8400-e29b-41d4-a716-446655810009'; + +/** + * Constant REPORT_SITE_ID + * + * @description + * Stable named site identity for private financial scope tests. + * + * @access public + * @since unreleased + */ +export const REPORT_SITE_ID = '810e8400-e29b-41d4-a716-446655810010'; + +/** + * Constant REPORT_CUSTOMER_ID + * + * @description + * Internal customer identity used without loading contacts or ordinary client resources. + * + * @access public + * @since unreleased + */ +export const REPORT_CUSTOMER_ID = '810e8400-e29b-41d4-a716-446655810011'; + +/** + * Constant REPORT_EQUIPMENT_ID + * + * @description + * Individual equipment identity used by allocation and named directory fixtures. + * + * @access public + * @since unreleased + */ +export const REPORT_EQUIPMENT_ID = '810e8400-e29b-41d4-a716-446655810012'; + +/** + * Function economicAmount + * + * @description + * Creates a complete exact metric while retaining the count of its source contributions. + * + * @access public + * @since unreleased + * + * @param {string} total - Exact six-place amount, including known zero. + * @param {number} contributionCount - Number of known source contributions retained by the fixture. + * + * @returns {MaintenanceEconomicAmount} Complete metric with no unknown valuation. + */ +export function economicAmount( + total = '0.000000', + contributionCount = 0, +): MaintenanceEconomicAmount { + return { total, knownTotal: total, complete: true, contributionCount, unknownCount: 0 }; +} + +/** + * Function maintenanceReportFixture + * + * @description + * Builds a reconciled private report with separate source dossiers, forecasts and procurement + * metrics. + * + * @access public + * @since unreleased + * + * @param {Partial} overrides - Explicit scenario-specific + * transport fields. + * + * @returns {MaintenanceEconomicReportOutput} Typed economic fixture without floating-point monetary + * values. + */ +export function maintenanceReportFixture( + overrides: Partial = {}, +): MaintenanceEconomicReportOutput { + const current = economicAmount('35.000000', 3), + frozen = economicAmount('20.000000', 2), + planned = economicAmount('24.000000', 2), + budget = economicAmount('100.000000', 1); + const rows: readonly MaintenanceEconomicRow[] = [ + { + id: REPORT_EQUIPMENT_ID, + name: 'Entrance extinguisher — EX-001', + identityState: 'captured', + allocationComplete: true, + current: economicAmount('10.000000', 1), + frozen: economicAmount('8.000000', 1), + planned: economicAmount('9.000000', 1), + budget: economicAmount(), + variance: '1.000000', + interventionIds: [REPORT_INTERVENTION_ID], + }, + { + id: '810e8400-e29b-41d4-a716-446655810013', + name: 'Hall extinguisher — EX-002', + identityState: 'captured', + allocationComplete: true, + current: economicAmount('20.000000', 1), + frozen: economicAmount('12.000000', 1), + planned: economicAmount('15.000000', 1), + budget: economicAmount(), + variance: '5.000000', + interventionIds: [REPORT_OTHER_INTERVENTION_ID], + }, + ]; + return { + '@id': '/api/organizations/org/maintenance-cost/reports', + '@type': 'MaintenanceEconomicReport', + id: 'org', + organizationId: 'org', + from: '2025-01-01', + to: '2025-01-31', + groupBy: 'equipment', + currency: 'EUR', + page: 1, + itemsPerPage: 30, + totalItems: 2, + interventionCount: 2, + publishedInterventionCount: 2, + liveInterventionCount: 0, + missingSnapshotCount: 0, + rows, + dossiers: [ + { + id: REPORT_INTERVENTION_ID, + number: 1, + name: 'Entrance repair', + status: 'published', + snapshotState: 'available', + }, + { + id: REPORT_OTHER_INTERVENTION_ID, + number: 2, + name: 'Hall repair', + status: 'published', + snapshotState: 'available', + }, + ], + unallocated: { + id: null, + name: null, + identityState: 'unallocated', + allocationComplete: false, + current: economicAmount('5.000000', 1), + frozen: economicAmount(), + planned: economicAmount(), + budget, + variance: null, + interventionIds: [REPORT_INTERVENTION_ID], + }, + current, + frozen, + planned, + budget, + variance: '11.000000', + reconciliation: { + sourceCurrent: current, + excludedCurrent: economicAmount(), + sourceFrozen: frozen, + excludedFrozen: economicAmount(), + sourcePlanned: planned, + excludedPlanned: economicAmount(), + reconciled: true, + }, + procurement: { + organizationId: 'org', + currency: 'EUR', + from: '2025-01-01T00:00:00Z', + to: '2025-02-01T00:00:00Z', + basis: 'order_created_at', + receiptScope: 'selected_orders_all_history', + orderCount: 1, + receiptCount: 2, + pendingIndividualizationCount: 1, + ordered: { total: '40.000000', knownTotal: '40.000000', complete: true }, + received: { total: '30.000000', knownTotal: '30.000000', complete: true }, + outstanding: { total: '10.000000', knownTotal: '10.000000', complete: true }, + returned: { total: '5.000000', knownTotal: '5.000000', complete: true }, + }, + ...overrides, + }; +} diff --git a/src/app/features/organization/features/maintenance-costs/state/index.ts b/src/app/features/organization/features/maintenance-costs/state/index.ts new file mode 100644 index 000000000..0d73f2546 --- /dev/null +++ b/src/app/features/organization/features/maintenance-costs/state/index.ts @@ -0,0 +1,11 @@ +export { + MaintenanceCostStore, + type MaintenanceCostStoreType, + maintenanceCostStoreEvents, +} from './maintenance-cost'; +export type { MaintenanceCostCommand, MaintenanceCostScope } from './maintenance-cost'; +export { + MaintenanceReportStore, + type MaintenanceReportStoreType, + type MaintenanceReportScope, +} from './maintenance-report'; diff --git a/src/app/features/organization/features/maintenance-costs/state/maintenance-cost/events/events.ts b/src/app/features/organization/features/maintenance-costs/state/maintenance-cost/events/events.ts new file mode 100644 index 000000000..99991f67b --- /dev/null +++ b/src/app/features/organization/features/maintenance-costs/state/maintenance-cost/events/events.ts @@ -0,0 +1,21 @@ +import { type } from '@ngrx/signals'; +import { eventGroup } from '@ngrx/signals/events'; +import type { FeedbackEventPayload } from '@core/request-state'; +import type { MaintenanceCostCommand } from '@features/organization/features/maintenance-costs/models'; + +/** + * Constant maintenanceCostStoreEvents + * + * @description + * Confirmed private writes let the page reset only the acknowledged draft. + */ +export const maintenanceCostStoreEvents = eventGroup({ + source: 'Maintenance Cost Store', + events: { + feedback: type(), + saved: type<{ + readonly organizationId: string; + readonly kind: MaintenanceCostCommand['kind']; + }>(), + }, +}); diff --git a/src/app/features/organization/features/maintenance-costs/state/maintenance-cost/index.ts b/src/app/features/organization/features/maintenance-costs/state/maintenance-cost/index.ts new file mode 100644 index 000000000..20b7aca84 --- /dev/null +++ b/src/app/features/organization/features/maintenance-costs/state/maintenance-cost/index.ts @@ -0,0 +1,4 @@ +export { MaintenanceCostStore, type MaintenanceCostStoreType } from './maintenance-cost.store'; +export { maintenanceCostStoreEvents } from './events/events'; +export type { MaintenanceCostCommand } from '@features/organization/features/maintenance-costs/models'; +export type { MaintenanceCostScope } from './models/maintenance-cost-state.interface'; diff --git a/src/app/features/organization/features/maintenance-costs/state/maintenance-cost/maintenance-cost.store.ts b/src/app/features/organization/features/maintenance-costs/state/maintenance-cost/maintenance-cost.store.ts new file mode 100644 index 000000000..5f520b973 --- /dev/null +++ b/src/app/features/organization/features/maintenance-costs/state/maintenance-cost/maintenance-cost.store.ts @@ -0,0 +1,555 @@ +import { isPlatformBrowser } from '@angular/common'; +import { computed, effect, inject, PLATFORM_ID, untracked } from '@angular/core'; +import { tapResponse } from '@ngrx/operators'; +import { + patchState, + signalStore, + type, + withComputed, + withHooks, + withMethods, + withState, +} from '@ngrx/signals'; +import { removeAllEntities, setAllEntities, withEntities } from '@ngrx/signals/entities'; +import { Dispatcher } from '@ngrx/signals/events'; +import { rxMethod } from '@ngrx/signals/rxjs-interop'; +import { + EMPTY, + catchError, + defer, + exhaustMap, + map, + pipe, + switchMap, + tap, + throwError, + type Observable, +} from 'rxjs'; +import { + idleCallState, + pendingCallState, + successCallState, + errorCallState, + toStoreError, + successFeedback, + toStoreFailureEventPayload, +} from '@core/request-state'; +import { AUTH_SESSION_PORT } from '@features/auth/ports'; +import { OrganizationPermissionService } from '@features/organization/access'; +import { OrganizationMemberService } from '@features/organization/data-access'; +import { + MaintenanceCostCommandRepository, + MaintenanceCostService, +} from '@features/organization/features/maintenance-costs/data-access'; +import type { + MaintenanceCostCommand, + MaintenanceRateOutput, +} from '@features/organization/features/maintenance-costs/models'; +import { ORGANIZATION_PERMISSION } from '@features/organization/models'; +import { toMemberSelectOption } from '@features/organization/utils'; +import { maintenanceCostStoreEvents } from './events/events'; +import type { + MaintenanceCostMutation, + MaintenanceCostScope, + MaintenanceCostState, +} from './models/maintenance-cost-state.interface'; + +/** + * Constant INITIAL_STATE + * + * @description + * Empty private state, never handed to server rendering or a new session. + */ +const INITIAL_STATE: MaintenanceCostState = { + scope: null, + scopeUserId: null, + journalCallState: idleCallState(), + scopeVersion: 0, + costCallState: idleCallState(), + currencyCallState: idleCallState(), + ratesCallState: idleCallState(), + membersCallState: idleCallState(), + writeCallState: idleCallState(), + command: null, + ratePage: 1, + rateTotal: 0, +}; + +/** + * Constant MaintenanceCostStore + * + * @description + * Browser-only financial workspace. Scope changes cancel reads, clear private facts and discard + * late replies without cancelling accepted writes. + */ +export const MaintenanceCostStore = signalStore( + withEntities({ entity: type(), collection: 'rate' }), + withState(INITIAL_STATE), + withComputed( + ( + store, + journal = inject(MaintenanceCostCommandRepository), + session = inject(AUTH_SESSION_PORT), + permissions = inject(OrganizationPermissionService), + ) => ({ + journalReady: computed(() => { + const scope = store.scope(); + return ( + store.journalCallState().status === 'success' && + !!scope && + session.isAuthenticated() && + scope.sessionRevision === session.sessionRevision() && + permissions.hasPermission(ORGANIZATION_PERMISSION.MAINTENANCE_COST_READ) && + store.scopeUserId() !== null && + store.scopeUserId() === journal.captureOwner(scope.organizationId, scope.sessionRevision) + ); + }), + writePending: computed(() => store.writeCallState().status === 'pending'), + uncertainWrite: computed( + () => + store.writeCallState().status === 'error' && + !!store.command() && + (store.writeCallState().error?.retryable === true || + store.writeCallState().error?.code === 0), + ), + }), + ), + withMethods( + ( + store, + api = inject(MaintenanceCostService), + members = inject(OrganizationMemberService), + permissions = inject(OrganizationPermissionService), + session = inject(AUTH_SESSION_PORT), + platform = inject(PLATFORM_ID), + dispatcher = inject(Dispatcher), + journal = inject(MaintenanceCostCommandRepository), + ) => { + const readable = (): boolean => + isPlatformBrowser(platform) && + session.isAuthenticated() && + permissions.hasPermission(ORGANIZATION_PERMISSION.MAINTENANCE_COST_READ); + const current = (scope: MaintenanceCostScope, generation: number): boolean => + readable() && + generation === store.scopeVersion() && + session.sessionRevision() === scope.sessionRevision && + store.scopeUserId() !== null && + store.scopeUserId() === journal.captureOwner(scope.organizationId, scope.sessionRevision) && + store.scope()?.organizationId === scope.organizationId && + store.scope()?.interventionId === scope.interventionId; + const readCost = rxMethod( + pipe( + switchMap((scope) => { + if ( + !scope?.interventionId || + !readable() || + scope.sessionRevision !== session.sessionRevision() + ) { + patchState(store, { costCallState: idleCallState() }); + return EMPTY; + } + const generation = store.scopeVersion(); + patchState(store, { costCallState: pendingCallState(store.costCallState().data) }); + return api.readCost(scope.organizationId, scope.interventionId).pipe( + tapResponse({ + next: (cost) => { + if ( + current(scope, generation) && + cost.organizationId === scope.organizationId && + cost.interventionId === scope.interventionId + ) + patchState(store, { costCallState: successCallState(cost) }); + }, + error: (error: unknown) => { + if (current(scope, generation)) + patchState(store, { + costCallState: errorCallState( + toStoreError(error), + store.costCallState().data, + ), + }); + }, + }), + ); + }), + ), + ); + const readCurrency = rxMethod( + pipe( + switchMap((scope) => { + if (!scope || !readable() || scope.sessionRevision !== session.sessionRevision()) { + patchState(store, { currencyCallState: idleCallState() }); + return EMPTY; + } + const generation = store.scopeVersion(); + patchState(store, { + currencyCallState: pendingCallState(store.currencyCallState().data), + }); + return api.readCurrency(scope.organizationId).pipe( + tapResponse({ + next: (currency) => { + if ( + current(scope, generation) && + currency.organizationId === scope.organizationId + ) + patchState(store, { currencyCallState: successCallState(currency) }); + }, + error: (error: unknown) => { + if (current(scope, generation)) + patchState(store, { + currencyCallState: errorCallState( + toStoreError(error), + store.currencyCallState().data, + ), + }); + }, + }), + ); + }), + ), + ); + const readRates = rxMethod<{ + readonly scope: MaintenanceCostScope; + readonly page: number; + } | null>( + pipe( + switchMap((query) => { + if ( + !query || + !readable() || + query.scope.sessionRevision !== session.sessionRevision() + ) { + patchState(store, { ratesCallState: idleCallState() }); + return EMPTY; + } + const { scope, page } = query, + generation = store.scopeVersion(); + patchState(store, { ratePage: page, ratesCallState: pendingCallState() }); + return api.listRates(scope.organizationId, { page, itemsPerPage: 30 }).pipe( + tapResponse({ + next: (collection) => { + if (current(scope, generation)) + patchState( + store, + setAllEntities([...collection.member], { collection: 'rate' }), + { rateTotal: collection.totalItems, ratesCallState: successCallState(null) }, + ); + }, + error: (error: unknown) => { + if (current(scope, generation)) + patchState(store, { ratesCallState: errorCallState(toStoreError(error)) }); + }, + }), + ); + }), + ), + ); + const readMembers = rxMethod( + pipe( + switchMap((scope) => { + if ( + !scope || + !readable() || + scope.sessionRevision !== session.sessionRevision() || + !permissions.hasPermission(ORGANIZATION_PERMISSION.MEMBERS_READ) + ) { + patchState(store, { membersCallState: idleCallState() }); + return EMPTY; + } + const generation = store.scopeVersion(); + patchState(store, { + membersCallState: pendingCallState(store.membersCallState().data), + }); + return members.listAll(scope.organizationId).pipe( + tapResponse({ + next: (roster) => { + if ( + current(scope, generation) && + permissions.hasPermission(ORGANIZATION_PERMISSION.MEMBERS_READ) + ) + patchState(store, { + membersCallState: successCallState( + roster + .filter((member) => member.isActive) + .map((member) => + toMemberSelectOption(member, scope.organizationId, member.id), + ), + ), + }); + }, + error: (error: unknown) => { + if (current(scope, generation)) + patchState(store, { membersCallState: errorCallState(toStoreError(error)) }); + }, + }), + ); + }), + ), + ); + const request = (command: MaintenanceCostCommand): Observable => { + switch (command.kind) { + case 'planning': + return api.writePlanning( + command.organizationId, + command.interventionId, + command.input, + command.revision, + ); + case 'expense': + return api.createExpense(command.organizationId, command.interventionId, command.input); + case 'currency': + return api.writeCurrency(command.organizationId, command.currency); + case 'rate': + return api.createRate(command.organizationId, command.input); + } + }; + const hydrate = rxMethod( + pipe( + switchMap((scope) => { + if (!scope || !readable()) return EMPTY; + const generation = store.scopeVersion(); + patchState(store, { journalCallState: pendingCallState() }); + return defer(() => + journal.readPending(scope.organizationId, scope.interventionId), + ).pipe( + tapResponse({ + next: (command) => { + if (!current(scope, generation)) return; + patchState(store, { + journalCallState: successCallState(null), + ...(command + ? { + command, + writeCallState: errorCallState( + toStoreError({ + type: 'about:blank', + status: 0, + title: 'Unconfirmed financial declaration', + detail: $localize`:@@maintenanceCost.recovery.uncertain:The previous declaration has no confirmed result. Retry it unchanged before creating another.`, + }), + ), + } + : {}), + }); + }, + error: (error: unknown) => { + if (current(scope, generation)) + patchState(store, { journalCallState: errorCallState(toStoreError(error)) }); + }, + }), + ); + }), + ), + ); + const write = rxMethod( + pipe( + exhaustMap((incoming) => { + const scope = store.scope(); + if ( + !scope || + !store.journalReady() || + !current(scope, store.scopeVersion()) || + !readable() || + scope.sessionRevision !== session.sessionRevision() || + !permissions.hasPermission(ORGANIZATION_PERMISSION.MAINTENANCE_COST_MANAGE) || + scope.organizationId !== incoming.organizationId || + ('interventionId' in incoming && scope.interventionId !== incoming.interventionId) + ) + return EMPTY; + const generation = store.scopeVersion(); + const wasUncertain = store.uncertainWrite(); + const command = wasUncertain + ? (store.command() ?? incoming) + : structuredClone(incoming); + const userId = store.scopeUserId(); + let transmitted = false; + let acknowledged = false; + const acknowledge = () => + defer(() => journal.acknowledge(command, scope.sessionRevision, userId)).pipe( + tap(() => { + acknowledged = true; + }), + catchError(() => + throwError(() => ({ + type: 'about:blank', + status: 0, + title: 'Unconfirmed local acknowledgement', + detail: $localize`:@@maintenanceCost.recovery.uncertain:The previous declaration has no confirmed result. Retry it unchanged before creating another.`, + })), + ), + ); + patchState(store, { command, writeCallState: pendingCallState() }); + return defer(() => journal.retain(command)).pipe( + switchMap(() => { + if (!current(scope, generation)) { + if (!store.command()) patchState(store, { writeCallState: idleCallState() }); + return EMPTY; + } + transmitted = true; + return request(command); + }), + switchMap((result) => acknowledge().pipe(map(() => result))), + catchError((error: unknown) => { + const failure = toStoreError(error); + return transmitted && !failure.retryable && failure.code !== 0 + ? acknowledge().pipe(switchMap(() => throwError(() => error))) + : throwError(() => error); + }), + tapResponse({ + next: (result) => { + if (!current(scope, generation)) { + if (!store.command()) patchState(store, { writeCallState: idleCallState() }); + return; + } + if (command.kind === 'expense' || command.kind === 'rate') hydrate(scope); + if ( + (command.kind === 'planning' || command.kind === 'expense') && + 'interventionId' in result + ) { + if ( + result.organizationId !== scope.organizationId || + result.interventionId !== scope.interventionId + ) { + patchState(store, { writeCallState: idleCallState(), command: null }); + return; + } + patchState(store, { costCallState: successCallState(result) }); + } + if (command.kind === 'currency' && 'locked' in result) { + if (result.organizationId !== scope.organizationId) { + patchState(store, { writeCallState: idleCallState(), command: null }); + return; + } + patchState(store, { currencyCallState: successCallState(result) }); + } + patchState(store, { command: null, writeCallState: successCallState(result) }); + if (command.kind === 'rate') readRates({ scope, page: store.ratePage() }); + dispatcher.dispatch( + maintenanceCostStoreEvents.saved({ + organizationId: command.organizationId, + kind: command.kind, + }), + ); + dispatcher.dispatch( + maintenanceCostStoreEvents.feedback( + successFeedback( + $localize`:@@maintenanceCost.feedback.saved:Financial information saved.`, + ), + ), + ); + }, + error: (error: unknown) => { + if (!current(scope, generation)) { + if (!store.command()) patchState(store, { writeCallState: idleCallState() }); + return; + } + const failure = + !transmitted && wasUncertain + ? { + ...toStoreError(error), + code: 0, + retryable: true, + message: $localize`:@@maintenanceCost.recovery.uncertain:The previous declaration has no confirmed result. Retry it unchanged before creating another.`, + } + : toStoreError(error); + if (acknowledged && (command.kind === 'expense' || command.kind === 'rate')) + hydrate(scope); + patchState(store, { writeCallState: errorCallState(failure) }); + dispatcher.dispatch( + maintenanceCostStoreEvents.feedback( + toStoreFailureEventPayload( + failure, + $localize`:@@maintenanceCost.feedback.failed:Financial information could not be saved.`, + ), + ), + ); + }, + }), + ); + }), + ), + ); + return { + /** + * Method setScope + * + * @description + * Invalidates reads and hidden private data when organization, intervention or session + * changes. + */ + setScope(scope: MaintenanceCostScope | null): void { + const userId = scope + ? journal.captureOwner(scope.organizationId, scope.sessionRevision) + : null; + if ( + JSON.stringify(scope) === JSON.stringify(store.scope()) && + store.scopeUserId() === userId + ) + return; + const pending = store.writePending(); + readCost(null); + readCurrency(null); + readRates(null); + readMembers(null); + hydrate(null); + patchState(store, INITIAL_STATE, removeAllEntities({ collection: 'rate' }), { + scope, + scopeUserId: userId, + scopeVersion: store.scopeVersion() + 1, + writeCallState: pending ? pendingCallState() : idleCallState(), + }); + hydrate(scope); + }, + readCost, + readCurrency, + readRates, + readMembers, + hydrate, + write, + /** + * Method retryWrite + * + * @description + * Reuses the unchanged original command after an uncertain transport result. + */ + retryWrite(): void { + const retained = store.command(); + if (retained && store.uncertainWrite()) write(retained); + }, + }; + }, + ), + withHooks( + ( + store, + session = inject(AUTH_SESSION_PORT), + permissions = inject(OrganizationPermissionService), + journal = inject(MaintenanceCostCommandRepository), + ) => ({ + onInit(): void { + effect(() => { + const scope = store.scope(); + if ( + scope && + (scope.sessionRevision !== session.sessionRevision() || + store.scopeUserId() !== + journal.captureOwner(scope.organizationId, scope.sessionRevision) || + !session.isAuthenticated() || + !permissions.hasPermission(ORGANIZATION_PERMISSION.MAINTENANCE_COST_READ)) + ) + untracked(() => store.setScope(null)); + }); + }, + }), + ), +); + +/** + * Type MaintenanceCostStoreType + * + * @description + * Public route-scoped financial store instance. + * + * @type MaintenanceCostStoreType + */ +export type MaintenanceCostStoreType = InstanceType; diff --git a/src/app/features/organization/features/maintenance-costs/state/maintenance-cost/models/maintenance-cost-state.interface.ts b/src/app/features/organization/features/maintenance-costs/state/maintenance-cost/models/maintenance-cost-state.interface.ts new file mode 100644 index 000000000..0abe319eb --- /dev/null +++ b/src/app/features/organization/features/maintenance-costs/state/maintenance-cost/models/maintenance-cost-state.interface.ts @@ -0,0 +1,191 @@ +import type { CallState } from '@core/request-state'; +import type { + MaintenanceCostOutput, + MaintenanceCurrencyOutput, + MaintenanceRateOutput, + MaintenanceCostCommand, +} from '@features/organization/features/maintenance-costs/models'; +import type { MemberSelectOption } from '@features/organization/models'; + +/** + * Interface MaintenanceCostScope + * @interface MaintenanceCostScope + * + * @description + * A browser display fence containing no credential or financial payload. + */ +export interface MaintenanceCostScope { + /** + * Property organizationId + * @readonly + * + * @description + * Owning organization route parameter. + * + * @type {string} + */ + readonly organizationId: string; + /** + * Property sessionRevision + * @readonly + * + * @description + * Exact contract value for sessionRevision. + * + * @type {number} + */ + readonly sessionRevision: number; + /** + * Property interventionId + * @readonly + * + * @description + * Optional financial dossier context supplied by the route query. + * + * @type {string | null} + */ + readonly interventionId: string | null; +} + +/** + * Type MaintenanceCostMutation + * + * @description + * Authorized response of one financial mutation. + * + * @type MaintenanceCostMutation + */ +export type MaintenanceCostMutation = + | MaintenanceCostOutput + | MaintenanceCurrencyOutput + | MaintenanceRateOutput; + +/** + * Interface MaintenanceCostState + * @interface MaintenanceCostState + * + * @description + * Route-owned private projections and explicit independently tracked asynchronous operations. + */ +export interface MaintenanceCostState { + /** + * Property scopeUserId + * @readonly + * + * @description + * Non-secret actor captured when the private display scope is established. + * + * @type {string | null} + */ + readonly scopeUserId: string | null; + /** + * Property journalCallState + * @readonly + * + * @description + * Recovery must finish before a new physical declaration is available. + * + * @type {CallState} + */ + readonly journalCallState: CallState; + /** + * Property scope + * @readonly + * + * @description + * Private organization and intervention context for this draft. + * + * @type {MaintenanceCostScope | null} + */ + readonly scope: MaintenanceCostScope | null; + /** + * Property scopeVersion + * @readonly + * + * @description + * Exact contract value for scopeVersion. + * + * @type {number} + */ + readonly scopeVersion: number; + /** + * Property costCallState + * @readonly + * + * @description + * Exact contract value for costCallState. + * + * @type {CallState} + */ + readonly costCallState: CallState; + /** + * Property currencyCallState + * @readonly + * + * @description + * Exact contract value for currencyCallState. + * + * @type {CallState} + */ + readonly currencyCallState: CallState; + /** + * Property ratesCallState + * @readonly + * + * @description + * Exact contract value for ratesCallState. + * + * @type {CallState} + */ + readonly ratesCallState: CallState; + /** + * Property membersCallState + * @readonly + * + * @description + * Exact contract value for membersCallState. + * + * @type {CallState} + */ + readonly membersCallState: CallState; + /** + * Property writeCallState + * @readonly + * + * @description + * Exact contract value for writeCallState. + * + * @type {CallState} + */ + readonly writeCallState: CallState; + /** + * Property command + * @readonly + * + * @description + * Exact contract value for command. + * + * @type {MaintenanceCostCommand | null} + */ + readonly command: MaintenanceCostCommand | null; + /** + * Property ratePage + * @readonly + * + * @description + * Exact contract value for ratePage. + * + * @type {number} + */ + readonly ratePage: number; + /** + * Property rateTotal + * @readonly + * + * @description + * Exact contract value for rateTotal. + * + * @type {number} + */ + readonly rateTotal: number; +} diff --git a/src/app/features/organization/features/maintenance-costs/state/maintenance-cost/testing/maintenance-cost.store.spec.ts b/src/app/features/organization/features/maintenance-costs/state/maintenance-cost/testing/maintenance-cost.store.spec.ts new file mode 100644 index 000000000..7505e24a4 --- /dev/null +++ b/src/app/features/organization/features/maintenance-costs/state/maintenance-cost/testing/maintenance-cost.store.spec.ts @@ -0,0 +1,589 @@ +import { PLATFORM_ID, signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { of, Subject, throwError } from 'rxjs'; +import { AUTH_SESSION_PORT } from '@features/auth/ports'; +import { OrganizationPermissionService } from '@features/organization/access'; +import { OrganizationMemberService } from '@features/organization/data-access'; +import { + MaintenanceCostService, + MaintenanceCostCommandRepository, +} from '@features/organization/features/maintenance-costs/data-access'; +import type { MaintenanceCostOutput } from '@features/organization/features/maintenance-costs/models'; +import { maintenanceCostFixture } from '@features/organization/features/maintenance-costs/models/maintenance-cost/testing/maintenance-cost.fixture'; +import { ORGANIZATION_PERMISSION } from '@features/organization/models'; +import { MaintenanceCostStore, type MaintenanceCostStoreType } from '../maintenance-cost.store'; +import type { MaintenanceCostScope } from '../models/maintenance-cost-state.interface'; + +describe('MaintenanceCostStore', () => { + let store: MaintenanceCostStoreType; + let api: { + readCost: ReturnType; + writePlanning: ReturnType; + createExpense: ReturnType; + readCurrency: ReturnType; + writeCurrency: ReturnType; + listRates: ReturnType; + createRate: ReturnType; + }; + let members: { listAll: ReturnType }; + let journal: { + captureOwner: ReturnType; + readPending: ReturnType; + retain: ReturnType; + acknowledge: ReturnType; + }; + const authenticated = signal(true), + revision = signal(1), + grants = signal([]); + const cost = maintenanceCostFixture(); + const scope: MaintenanceCostScope = { + organizationId: cost.organizationId, + interventionId: cost.interventionId, + sessionRevision: 1, + }; + const permission = { hasPermission: (value: string) => grants().includes(value) }; + const setup = async (platform = 'browser'): Promise => { + TestBed.configureTestingModule({ + providers: [ + MaintenanceCostStore, + { provide: MaintenanceCostCommandRepository, useValue: journal }, + { provide: MaintenanceCostService, useValue: api }, + { provide: OrganizationMemberService, useValue: members }, + { provide: OrganizationPermissionService, useValue: permission }, + { + provide: AUTH_SESSION_PORT, + useValue: { isAuthenticated: authenticated, sessionRevision: revision }, + }, + { provide: PLATFORM_ID, useValue: platform }, + ], + }); + store = TestBed.inject(MaintenanceCostStore); + store.setScope(scope); + await vi.waitFor(() => expect(store.journalCallState().status).not.toBe('pending')); + }; + beforeEach(() => { + authenticated.set(true); + revision.set(1); + grants.set([ + ORGANIZATION_PERMISSION.MAINTENANCE_COST_READ, + ORGANIZATION_PERMISSION.MAINTENANCE_COST_MANAGE, + ]); + api = { + readCost: vi.fn().mockReturnValue(of(cost)), + writePlanning: vi.fn().mockReturnValue(of(cost)), + createExpense: vi.fn().mockReturnValue(of(cost)), + readCurrency: vi + .fn() + .mockReturnValue(of({ organizationId: 'org', currency: 'EUR', locked: false })), + writeCurrency: vi + .fn() + .mockReturnValue(of({ organizationId: 'org', currency: 'EUR', locked: false })), + listRates: vi.fn().mockReturnValue(of({ member: [], totalItems: 0 })), + createRate: vi.fn().mockReturnValue( + of({ + id: 'rate', + memberId: 'member', + currency: 'EUR', + hourlyAmount: '50.000000', + effectiveFrom: '2025-01-01', + replayed: false, + }), + ), + }; + members = { listAll: vi.fn().mockReturnValue(of([])) }; + journal = { + captureOwner: vi.fn().mockReturnValue('user-a'), + readPending: vi.fn().mockResolvedValue(null), + retain: vi.fn().mockResolvedValue(undefined), + acknowledge: vi.fn().mockResolvedValue(undefined), + }; + }); + it.each(['server', 'no-read', 'anonymous'])( + 'does not fetch any private collection in %s', + async (mode) => { + if (mode === 'no-read') grants.set([ORGANIZATION_PERMISSION.MAINTENANCE_COST_MANAGE]); + if (mode === 'anonymous') authenticated.set(false); + await setup(mode === 'server' ? 'server' : 'browser'); + store.readCost(scope); + store.readCurrency(scope); + store.readRates({ scope, page: 1 }); + store.readMembers(scope); + expect(api.readCost).not.toHaveBeenCalled(); + expect(api.readCurrency).not.toHaveBeenCalled(); + expect(api.listRates).not.toHaveBeenCalled(); + expect(members.listAll).not.toHaveBeenCalled(); + expect(store.costCallState().data).toBeNull(); + }, + ); + it('cancels obsolete reads and clears private current/frozen facts on a scope change', async () => { + await setup(); + const old = new Subject(); + api.readCost.mockReturnValueOnce(old); + store.readCost(scope); + expect(old.observed).toBe(true); + const other = { ...scope, organizationId: 'other' }; + store.setScope(other); + expect(old.observed).toBe(false); + expect(store.costCallState().data).toBeNull(); + old.next(cost); + expect(store.costCallState().data).toBeNull(); + api.readCost.mockReturnValue(of({ ...cost, organizationId: 'other' })); + store.readCost(other); + expect(store.costCallState().data?.organizationId).toBe('other'); + }); + it('never accepts a response with a foreign organization or intervention identity', async () => { + await setup(); + api.readCost.mockReturnValue(of({ ...cost, organizationId: 'foreign' })); + store.readCost(scope); + expect(store.costCallState().data).toBeNull(); + api.readCost.mockReturnValue(of({ ...cost, interventionId: 'other' })); + store.readCost(scope); + expect(store.costCallState().data).toBeNull(); + api.readCurrency.mockReturnValue( + of({ organizationId: 'foreign', currency: 'EUR', locked: true }), + ); + store.readCurrency(scope); + expect(store.currencyCallState().data).toBeNull(); + }); + it('passes planning revision zero independently and retains the dossier on stale failure', async () => { + await setup(); + store.readCost(scope); + api.writePlanning.mockReturnValue( + throwError(() => ({ type: 'about:blank', status: 412, title: 'Stale', detail: 'Compare' })), + ); + const input = { + plannedBudget: '9007199254740993.123456', + estimatedMinutes: null, + resources: [], + }; + store.write({ + kind: 'planning', + organizationId: 'org', + interventionId: cost.interventionId, + input, + revision: 0, + }); + await vi.waitFor(() => expect(store.writeCallState().status).toBe('error')); + expect(api.writePlanning).toHaveBeenCalledExactlyOnceWith('org', cost.interventionId, input, 0); + expect(store.writeCallState().error?.code).toBe(412); + expect(store.costCallState().data).toEqual(cost); + expect(store.command()).toMatchObject({ revision: 0, input }); + expect(store.uncertainWrite()).toBe(false); + }); + it('does not cancel an accepted expense and discards its late result after a context replacement', async () => { + await setup(); + const accepted = new Subject(); + api.createExpense.mockReturnValue(accepted); + const command = { + kind: 'expense' as const, + organizationId: 'org', + interventionId: cost.interventionId, + input: { + clientId: 'stable', + amount: '12.123456', + description: 'Repair', + incurredAt: '2025-01-01T00:00:00Z', + }, + }; + store.write(command); + store.write(command); + await vi.waitFor(() => expect(api.createExpense).toHaveBeenCalledTimes(1)); + expect(api.createExpense).toHaveBeenCalledTimes(1); + store.setScope({ ...scope, organizationId: 'other' }); + expect(accepted.observed).toBe(true); + expect(store.writePending()).toBe(true); + expect(store.command()).toBeNull(); + accepted.next(cost); + accepted.complete(); + await vi.waitFor(() => expect(store.writePending()).toBe(false)); + expect(store.writePending()).toBe(false); + expect(store.costCallState().data).toBeNull(); + expect(store.writeCallState().data).toBeNull(); + }); + it('retries an uncertain expense using the frozen exact key and original body', async () => { + await setup(); + api.createExpense + .mockReturnValueOnce( + throwError(() => ({ + type: 'about:blank', + status: 0, + title: 'Network', + detail: 'Lost reply', + })), + ) + .mockReturnValue(of(cost)); + const input = { + clientId: 'stable-operation', + amount: '12.123456', + description: 'Original repair', + incurredAt: '2025-01-01T00:00:00Z', + }; + store.write({ + kind: 'expense', + organizationId: 'org', + interventionId: cost.interventionId, + input, + }); + await vi.waitFor(() => expect(store.uncertainWrite()).toBe(true)); + input.description = 'Changed local object'; + input.amount = '99'; + store.retryWrite(); + await vi.waitFor(() => expect(store.command()).toBeNull()); + expect(api.createExpense.mock.calls[1]?.[2]).toEqual({ + clientId: 'stable-operation', + amount: '12.123456', + description: 'Original repair', + incurredAt: '2025-01-01T00:00:00Z', + }); + expect(store.command()).toBeNull(); + }); + it('retries an uncertain rate unchanged and refreshes only its bounded current history page', async () => { + await setup(); + store.readRates({ scope, page: 2 }); + api.createRate + .mockReturnValueOnce( + throwError(() => ({ + type: 'about:blank', + status: 503, + title: 'Unavailable', + detail: 'Lost reply', + })), + ) + .mockReturnValue( + of({ + id: 'rate', + currency: 'EUR', + hourlyAmount: '50.000000', + effectiveFrom: '2025-01-01', + memberId: 'member', + replayed: true, + }), + ); + const input = { + clientId: 'stable-rate', + memberId: 'member', + hourlyAmount: '50.000000', + effectiveFrom: '2025-01-01', + }; + store.write({ kind: 'rate', organizationId: 'org', input }); + await vi.waitFor(() => expect(store.uncertainWrite()).toBe(true)); + store.retryWrite(); + await vi.waitFor(() => expect(store.command()).toBeNull()); + expect(api.createRate.mock.calls[1]?.[1]).toEqual(input); + expect(api.listRates).toHaveBeenLastCalledWith('org', { page: 2, itemsPerPage: 30 }); + }); + it('requires finance management and matching command scope before any write', async () => { + await setup(); + grants.set([ORGANIZATION_PERMISSION.MAINTENANCE_COST_READ]); + store.write({ kind: 'currency', organizationId: 'org', currency: 'USD' }); + expect(api.writeCurrency).not.toHaveBeenCalled(); + grants.set([ + ORGANIZATION_PERMISSION.MAINTENANCE_COST_READ, + ORGANIZATION_PERMISSION.MAINTENANCE_COST_MANAGE, + ]); + store.write({ kind: 'currency', organizationId: 'foreign', currency: 'USD' }); + expect(api.writeCurrency).not.toHaveBeenCalled(); + store.write({ + kind: 'expense', + organizationId: 'org', + interventionId: 'foreign', + input: { + clientId: 'stable', + amount: '1', + description: 'No', + incurredAt: '2025-01-01T00:00:00Z', + }, + }); + expect(api.createExpense).not.toHaveBeenCalled(); + }); + it.each(['session', 'permission', 'logout'])( + 'clears private data when %s changes', + async (mode) => { + await setup(); + store.readCost(scope); + store.readCurrency(scope); + expect(store.costCallState().data).toEqual(cost); + if (mode === 'session') revision.set(2); + if (mode === 'permission') grants.set([]); + if (mode === 'logout') authenticated.set(false); + TestBed.tick(); + expect(store.scope()).toBeNull(); + expect(store.costCallState().data).toBeNull(); + expect(store.currencyCallState().data).toBeNull(); + expect(store.rateEntities()).toEqual([]); + }, + ); + it('does not request the member directory without its independent permission', async () => { + await setup(); + store.readMembers(scope); + expect(members.listAll).not.toHaveBeenCalled(); + grants.set([...grants(), ORGANIZATION_PERMISSION.MEMBERS_READ]); + store.readMembers(scope); + expect(members.listAll).toHaveBeenCalledExactlyOnceWith('org'); + }); + + it('blocks declarations until durable recovery succeeds and permits an explicit recovery retry', async () => { + journal.readPending.mockRejectedValueOnce(new Error('Storage unavailable')); + await setup(); + expect(store.journalReady()).toBe(false); + store.write({ kind: 'currency', organizationId: 'org', currency: 'EUR' }); + expect(api.writeCurrency).not.toHaveBeenCalled(); + store.hydrate(scope); + await vi.waitFor(() => expect(store.journalReady()).toBe(true)); + store.write({ kind: 'currency', organizationId: 'org', currency: 'EUR' }); + await vi.waitFor(() => expect(api.writeCurrency).toHaveBeenCalledTimes(1)); + }); + + it('transmits no expense until its exact durable intention has committed', async () => { + await setup(); + let release!: () => void; + journal.retain.mockReturnValueOnce( + new Promise((resolve) => { + release = resolve; + }), + ); + const command = { + kind: 'expense' as const, + organizationId: 'org', + interventionId: cost.interventionId, + input: { + clientId: 'persist-first', + amount: '12.123456', + description: 'Original', + incurredAt: '2025-01-01T00:00:00Z', + }, + }; + store.write(command); + expect(store.writePending()).toBe(true); + expect(journal.retain).toHaveBeenCalledExactlyOnceWith(command); + expect(api.createExpense).not.toHaveBeenCalled(); + release(); + await vi.waitFor(() => expect(api.createExpense).toHaveBeenCalledTimes(1)); + expect(api.createExpense.mock.calls[0]?.[2]).toEqual(command.input); + }); + + it('clears private scope and fences an accepted response when the actor changes', async () => { + const actor = signal('user-a'); + journal.captureOwner.mockImplementation(() => actor()); + await setup(); + store.readCost(scope); + const accepted = new Subject(); + api.createExpense.mockReturnValueOnce(accepted); + store.write({ + kind: 'expense', + organizationId: 'org', + interventionId: cost.interventionId, + input: { + clientId: 'actor-bound', + amount: '1', + description: 'Original', + incurredAt: '2025-01-01T00:00:00Z', + }, + }); + await vi.waitFor(() => expect(api.createExpense).toHaveBeenCalledTimes(1)); + actor.set('user-b'); + TestBed.tick(); + expect(store.scope()).toBeNull(); + expect(store.costCallState().data).toBeNull(); + accepted.next(cost); + accepted.complete(); + await vi.waitFor(() => expect(store.writePending()).toBe(false)); + expect(journal.acknowledge).toHaveBeenCalledWith( + expect.objectContaining({ kind: 'expense' }), + 1, + 'user-a', + ); + expect(store.costCallState().data).toBeNull(); + expect(store.writeCallState().data).toBeNull(); + }); + + it('retains exact retry when a confirmed response cannot be acknowledged durably', async () => { + await setup(); + journal.acknowledge.mockRejectedValueOnce(new Error('Journal deletion failed')); + const command = { + kind: 'expense' as const, + organizationId: 'org', + interventionId: cost.interventionId, + input: { + clientId: 'acknowledgement-bound', + amount: '1', + description: 'Original', + incurredAt: '2025-01-01T00:00:00Z', + }, + }; + store.write(command); + await vi.waitFor(() => expect(store.uncertainWrite()).toBe(true)); + expect(store.command()).toEqual(command); + store.retryWrite(); + await vi.waitFor(() => expect(store.command()).toBeNull()); + expect(api.createExpense.mock.calls[1]?.[2]).toEqual(command.input); + }); + + it('waits for a definite refusal acknowledgement and preserves exact retry when deletion fails', async () => { + await setup(); + api.createExpense.mockReturnValueOnce( + throwError(() => ({ + type: 'about:blank', + status: 422, + title: 'Invalid expense', + detail: 'Refused', + })), + ); + let rejectAcknowledgement!: (error: Error) => void; + journal.acknowledge.mockReturnValueOnce( + new Promise((_resolve, reject) => { + rejectAcknowledgement = reject; + }), + ); + const command = { + kind: 'expense' as const, + organizationId: 'org', + interventionId: cost.interventionId, + input: { + clientId: 'refused-acknowledgement-bound', + amount: '9007199254740993.123456', + description: 'Original refused fact', + incurredAt: '2025-01-01T00:00:00Z', + }, + }; + store.write(command); + await vi.waitFor(() => expect(journal.acknowledge).toHaveBeenCalledTimes(1)); + expect(store.writePending()).toBe(true); + expect(journal.readPending).toHaveBeenCalledTimes(1); + store.write({ kind: 'currency', organizationId: 'org', currency: 'USD' }); + expect(api.writeCurrency).not.toHaveBeenCalled(); + rejectAcknowledgement(new Error('Journal deletion failed')); + await vi.waitFor(() => expect(store.uncertainWrite()).toBe(true)); + expect(store.writeCallState().error?.code).toBe(0); + expect(store.command()).toEqual(command); + expect(journal.readPending).toHaveBeenCalledTimes(1); + store.retryWrite(); + await vi.waitFor(() => expect(store.command()).toBeNull()); + expect(api.createExpense.mock.calls[1]?.[2]).toEqual(command.input); + }); + + it.each(['confirmed', 'refused'])( + 'keeps new declarations locked when recovery after an acknowledged %s expense fails', + async (outcome) => { + await setup(); + if (outcome === 'refused') + api.createExpense.mockReturnValueOnce( + throwError(() => ({ + type: 'about:blank', + status: 422, + title: 'Invalid expense', + detail: 'Refused', + })), + ); + journal.readPending.mockRejectedValueOnce(new Error('Next intention unavailable')); + store.write({ + kind: 'expense', + organizationId: 'org', + interventionId: cost.interventionId, + input: { + clientId: 'recover-before-unlock', + amount: '12.123456', + description: 'Original', + incurredAt: '2025-01-01T00:00:00Z', + }, + }); + await vi.waitFor(() => expect(store.journalCallState().status).toBe('error')); + expect(journal.acknowledge).toHaveBeenCalledTimes(1); + expect(store.journalReady()).toBe(false); + store.write({ kind: 'currency', organizationId: 'org', currency: 'USD' }); + expect(api.writeCurrency).not.toHaveBeenCalled(); + if (outcome === 'confirmed') expect(store.costCallState().data).toEqual(cost); + store.hydrate(scope); + await vi.waitFor(() => expect(store.journalReady()).toBe(true)); + }, + ); + + it('refuses an old exact retry synchronously after actor replacement without waiting for effects', async () => { + const actor = signal('user-a'); + journal.captureOwner.mockImplementation(() => actor()); + await setup(); + api.createExpense.mockReturnValueOnce( + throwError(() => ({ + type: 'about:blank', + status: 0, + title: 'Lost reply', + detail: 'Unknown', + })), + ); + store.write({ + kind: 'expense', + organizationId: 'org', + interventionId: cost.interventionId, + input: { + clientId: 'old-actor', + amount: '1', + description: 'Private original', + incurredAt: '2025-01-01T00:00:00Z', + }, + }); + await vi.waitFor(() => expect(store.uncertainWrite()).toBe(true)); + actor.set('user-b'); + expect(store.journalReady()).toBe(false); + store.retryWrite(); + expect(journal.retain).toHaveBeenCalledTimes(1); + expect(api.createExpense).toHaveBeenCalledTimes(1); + }); + + it('never acknowledges a first untransmitted expense when local retention fails', async () => { + await setup(); + journal.retain.mockRejectedValueOnce(new DOMException('Quota exceeded', 'QuotaExceededError')); + store.write({ + kind: 'expense', + organizationId: 'org', + interventionId: cost.interventionId, + input: { + clientId: 'untransmitted', + amount: '1', + description: 'Local draft', + incurredAt: '2025-01-01T00:00:00Z', + }, + }); + await vi.waitFor(() => expect(store.writeCallState().status).toBe('error')); + expect(api.createExpense).not.toHaveBeenCalled(); + expect(journal.acknowledge).not.toHaveBeenCalled(); + expect(store.uncertainWrite()).toBe(false); + expect(store.command()).toMatchObject({ + input: { clientId: 'untransmitted', description: 'Local draft' }, + }); + }); + + it('keeps a previous unknown expense locked when exact retry cannot access its durable journal', async () => { + await setup(); + api.createExpense.mockReturnValueOnce( + throwError(() => ({ + type: 'about:blank', + status: 0, + title: 'Lost reply', + detail: 'Unknown', + })), + ); + const command = { + kind: 'expense' as const, + organizationId: 'org', + interventionId: cost.interventionId, + input: { + clientId: 'previous-unknown', + amount: '1', + description: 'Original fact', + incurredAt: '2025-01-01T00:00:00Z', + }, + }; + store.write(command); + await vi.waitFor(() => expect(store.uncertainWrite()).toBe(true)); + journal.retain.mockRejectedValueOnce(new Error('Storage inaccessible')); + store.retryWrite(); + await vi.waitFor(() => expect(store.writeCallState().status).toBe('error')); + expect(store.uncertainWrite()).toBe(true); + expect(store.command()).toEqual(command); + expect(journal.acknowledge).not.toHaveBeenCalled(); + expect(api.createExpense).toHaveBeenCalledTimes(1); + store.retryWrite(); + await vi.waitFor(() => expect(store.command()).toBeNull()); + expect(api.createExpense.mock.calls[1]?.[2]).toEqual(command.input); + }); +}); diff --git a/src/app/features/organization/features/maintenance-costs/state/maintenance-report/index.ts b/src/app/features/organization/features/maintenance-costs/state/maintenance-report/index.ts new file mode 100644 index 000000000..9a34a6036 --- /dev/null +++ b/src/app/features/organization/features/maintenance-costs/state/maintenance-report/index.ts @@ -0,0 +1,5 @@ +export { + MaintenanceReportStore, + type MaintenanceReportStoreType, +} from './maintenance-report.store'; +export type { MaintenanceReportScope } from './models/maintenance-report-state.interface'; diff --git a/src/app/features/organization/features/maintenance-costs/state/maintenance-report/maintenance-report.store.ts b/src/app/features/organization/features/maintenance-costs/state/maintenance-report/maintenance-report.store.ts new file mode 100644 index 000000000..7f5886a6e --- /dev/null +++ b/src/app/features/organization/features/maintenance-costs/state/maintenance-report/maintenance-report.store.ts @@ -0,0 +1,240 @@ +import { isPlatformBrowser } from '@angular/common'; +import { effect, inject, PLATFORM_ID, untracked } from '@angular/core'; +import { tapResponse } from '@ngrx/operators'; +import { patchState, signalStore, type, withHooks, withMethods, withState } from '@ngrx/signals'; +import { removeAllEntities, setAllEntities, withEntities } from '@ngrx/signals/entities'; +import { rxMethod } from '@ngrx/signals/rxjs-interop'; +import { EMPTY, pipe, switchMap } from 'rxjs'; +import { + errorCallState, + idleCallState, + pendingCallState, + successCallState, + toStoreError, +} from '@core/request-state'; +import { AUTH_SESSION_PORT } from '@features/auth/ports'; +import { OrganizationPermissionService } from '@features/organization/access'; +import { MaintenanceReportService } from '@features/organization/features/maintenance-costs/data-access'; +import type { + MaintenanceFinancialDirectoryQuery, + MaintenanceFinancialDossierOutput, + MaintenanceReportQuery, +} from '@features/organization/features/maintenance-costs/models'; +import { ORGANIZATION_PERMISSION } from '@features/organization/models'; +import type { + MaintenanceReportScope, + MaintenanceReportState, +} from './models/maintenance-report-state.interface'; + +/** + * Constant INITIAL_STATE + * + * @description + * Empty private report state; neither SSR nor a replacement session receives previous financial + * facts. + */ +const INITIAL_STATE: MaintenanceReportState = { + scope: null, + scopeVersion: 0, + reportQuery: null, + reportCallState: idleCallState(), + directoryQuery: null, + directoryCallState: idleCallState(), + directoryTotal: 0, +}; + +/** + * Constant MaintenanceReportStore + * + * @description + * Route-owned financial reports and paginated source identities, guarded at request and reply + * boundaries. + */ +export const MaintenanceReportStore = signalStore( + withEntities({ entity: type(), collection: 'dossier' }), + withState(INITIAL_STATE), + withMethods( + ( + store, + api = inject(MaintenanceReportService), + session = inject(AUTH_SESSION_PORT), + permissions = inject(OrganizationPermissionService), + platform = inject(PLATFORM_ID), + ) => { + const readable = (): boolean => + isPlatformBrowser(platform) && + session.isAuthenticated() && + permissions.hasPermission(ORGANIZATION_PERMISSION.MAINTENANCE_COST_READ); + const current = (scope: MaintenanceReportScope, generation: number): boolean => + readable() && + generation === store.scopeVersion() && + store.scope()?.organizationId === scope.organizationId && + session.sessionRevision() === scope.sessionRevision; + const readReport = rxMethod<{ + readonly scope: MaintenanceReportScope; + readonly query: MaintenanceReportQuery; + } | null>( + pipe( + switchMap((request) => { + if ( + !request || + !readable() || + request.scope.sessionRevision !== session.sessionRevision() || + request.scope.organizationId !== store.scope()?.organizationId + ) { + patchState(store, { reportCallState: idleCallState() }); + return EMPTY; + } + const { scope, query } = request, + generation = store.scopeVersion(); + patchState(store, { + reportQuery: query, + reportCallState: pendingCallState(), + }); + return api.readReport(scope.organizationId, query).pipe( + tapResponse({ + next: (report) => { + if (!current(scope, generation)) return; + if ( + report.organizationId !== scope.organizationId || + report.from !== query.from || + report.to !== query.to || + report.groupBy !== query.groupBy + ) { + patchState(store, { + reportCallState: errorCallState( + toStoreError({ + type: 'about:blank', + status: 502, + title: $localize`:@@maintenanceReport.error.scope:Financial response does not match the requested scope.`, + }), + ), + }); + return; + } + patchState(store, { reportCallState: successCallState(report) }); + }, + error: (error: unknown) => { + if (current(scope, generation)) + patchState(store, { + reportCallState: errorCallState(toStoreError(error)), + }); + }, + }), + ); + }), + ), + ); + const readDirectory = rxMethod<{ + readonly scope: MaintenanceReportScope; + readonly query: MaintenanceFinancialDirectoryQuery; + } | null>( + pipe( + switchMap((request) => { + if ( + !request || + !readable() || + request.scope.sessionRevision !== session.sessionRevision() || + request.scope.organizationId !== store.scope()?.organizationId + ) { + patchState(store, removeAllEntities({ collection: 'dossier' }), { + directoryCallState: idleCallState(), + directoryTotal: 0, + }); + return EMPTY; + } + const { scope, query } = request, + generation = store.scopeVersion(); + patchState(store, removeAllEntities({ collection: 'dossier' }), { + directoryQuery: query, + directoryTotal: 0, + directoryCallState: pendingCallState(), + }); + return api.listDossiers(scope.organizationId, query).pipe( + tapResponse({ + next: (collection) => { + if (current(scope, generation)) + patchState( + store, + setAllEntities([...collection.member], { collection: 'dossier' }), + { + directoryTotal: collection.totalItems, + directoryCallState: successCallState(null), + }, + ); + }, + error: (error: unknown) => { + if (current(scope, generation)) + patchState(store, { directoryCallState: errorCallState(toStoreError(error)) }); + }, + }), + ); + }), + ), + ); + return { + /** + * Method setScope + * @method setScope + * + * @description + * Cancels private reads and clears all rendered identities before a new organization or + * account scope. + * + * @access public + * + * @param {MaintenanceReportScope | null} scope - Authorized browser display scope or + * revoked context. + * + * @returns {void} No return value. + */ + setScope(scope: MaintenanceReportScope | null): void { + if ( + scope?.organizationId === store.scope()?.organizationId && + scope?.sessionRevision === store.scope()?.sessionRevision + ) + return; + const nextVersion = store.scopeVersion() + 1; + readReport(null); + readDirectory(null); + patchState(store, INITIAL_STATE, removeAllEntities({ collection: 'dossier' }), { + scope, + scopeVersion: nextVersion, + }); + }, + readReport, + readDirectory, + }; + }, + ), + withHooks( + ( + store, + session = inject(AUTH_SESSION_PORT), + permissions = inject(OrganizationPermissionService), + ) => ({ + onInit(): void { + effect(() => { + const scope = store.scope(); + if ( + scope && + (!session.isAuthenticated() || + session.sessionRevision() !== scope.sessionRevision || + !permissions.hasPermission(ORGANIZATION_PERMISSION.MAINTENANCE_COST_READ)) + ) + untracked(() => store.setScope(null)); + }); + }, + }), + ), +); + +/** + * Type MaintenanceReportStoreType + * + * @description + * Public route-scoped private reporting store instance. + * + * @type MaintenanceReportStoreType + */ +export type MaintenanceReportStoreType = InstanceType; diff --git a/src/app/features/organization/features/maintenance-costs/state/maintenance-report/models/maintenance-report-state.interface.ts b/src/app/features/organization/features/maintenance-costs/state/maintenance-report/models/maintenance-report-state.interface.ts new file mode 100644 index 000000000..6683d33c3 --- /dev/null +++ b/src/app/features/organization/features/maintenance-costs/state/maintenance-report/models/maintenance-report-state.interface.ts @@ -0,0 +1,141 @@ +import type { CallState } from '@core/request-state'; +import type { + MaintenanceEconomicReportOutput, + MaintenanceFinancialDirectoryQuery, + MaintenanceReportQuery, +} from '@features/organization/features/maintenance-costs/models'; + +/** + * Interface MaintenanceReportScope + * @interface MaintenanceReportScope + * + * @description + * Credential-free display boundary; private replies never populate another account or organization. + */ +export interface MaintenanceReportScope { + /** + * Property organizationId + * @readonly + * + * @description + * Authorized owning organization identifier. + * + * @access public + * + * @type {string} + */ + readonly organizationId: string; + + /** + * Property sessionRevision + * @readonly + * + * @description + * Current authenticated account-session generation. + * + * @access public + * + * @type {number} + */ + readonly sessionRevision: number; +} + +/** + * Interface MaintenanceReportState + * @interface MaintenanceReportState + * + * @description + * Independent private report and named directory queries with explicit request state. + */ +export interface MaintenanceReportState { + /** + * Property scope + * @readonly + * + * @description + * Current authorized browser scope, never serialized into SSR. + * + * @access public + * + * @type {MaintenanceReportScope | null} + */ + readonly scope: MaintenanceReportScope | null; + + /** + * Property scopeVersion + * @readonly + * + * @description + * Monotonic local invalidation generation. + * + * @access public + * + * @type {number} + */ + readonly scopeVersion: number; + + /** + * Property reportQuery + * @readonly + * + * @description + * Requested bounded report page and filters. + * + * @access public + * + * @type {MaintenanceReportQuery | null} + */ + readonly reportQuery: MaintenanceReportQuery | null; + + /** + * Property reportCallState + * @readonly + * + * @description + * Report request lifecycle; a changed scope never labels prior totals as current results. + * + * @access public + * + * @type {CallState} + */ + readonly reportCallState: CallState; + + /** + * Property directoryQuery + * @readonly + * + * @description + * Requested named source directory page. + * + * @access public + * + * @type {MaintenanceFinancialDirectoryQuery | null} + */ + readonly directoryQuery: MaintenanceFinancialDirectoryQuery | null; + + /** + * Property directoryCallState + * @readonly + * + * @description + * Independent directory lifecycle without duplicated entity data. + * + * @access public + * + * @type {CallState} + */ + readonly directoryCallState: CallState; + + /** + * Property directoryTotal + * @readonly + * + * @description + * Server-wide directory count for the active filters. + * + * @access public + * + * @type {number} + */ + readonly directoryTotal: number; +} diff --git a/src/app/features/organization/features/maintenance-costs/state/maintenance-report/testing/maintenance-report.store.spec.ts b/src/app/features/organization/features/maintenance-costs/state/maintenance-report/testing/maintenance-report.store.spec.ts new file mode 100644 index 000000000..9a0d69d40 --- /dev/null +++ b/src/app/features/organization/features/maintenance-costs/state/maintenance-report/testing/maintenance-report.store.spec.ts @@ -0,0 +1,191 @@ +import { PLATFORM_ID, signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { of, Subject, throwError } from 'rxjs'; +import type { HydraCollection } from '@core/api/models'; +import { AUTH_SESSION_PORT } from '@features/auth/ports'; +import { OrganizationPermissionService } from '@features/organization/access'; +import { MaintenanceReportService } from '@features/organization/features/maintenance-costs/data-access'; +import type { + MaintenanceEconomicReportOutput, + MaintenanceFinancialDossierOutput, + MaintenanceReportQuery, +} from '@features/organization/features/maintenance-costs/models'; +import { maintenanceReportFixture } from '@features/organization/features/maintenance-costs/models/report/testing/maintenance-report.fixture'; +import { ORGANIZATION_PERMISSION } from '@features/organization/models'; +import { + MaintenanceReportStore, + type MaintenanceReportStoreType, +} from '../maintenance-report.store'; + +describe('MaintenanceReportStore', () => { + let store: MaintenanceReportStoreType; + let api: { readReport: ReturnType; listDossiers: ReturnType }; + const revision = signal(1), + authenticated = signal(true), + grants = signal([]); + const scope = { organizationId: 'org', sessionRevision: 1 }; + const query: MaintenanceReportQuery = { + from: '2025-01-01', + to: '2025-01-31', + groupBy: 'equipment', + page: 1, + itemsPerPage: 30, + }; + const empty: HydraCollection = { + '@id': '/api/organizations/org/maintenance-cost/dossiers', + '@type': 'Collection', + member: [], + totalItems: 0, + }; + const setup = (platform = 'browser'): void => { + TestBed.configureTestingModule({ + providers: [ + MaintenanceReportStore, + { provide: PLATFORM_ID, useValue: platform }, + { provide: MaintenanceReportService, useValue: api }, + { + provide: OrganizationPermissionService, + useValue: { hasPermission: (value: string) => grants().includes(value) }, + }, + { + provide: AUTH_SESSION_PORT, + useValue: { sessionRevision: revision, isAuthenticated: authenticated }, + }, + ], + }); + store = TestBed.inject(MaintenanceReportStore); + store.setScope(scope); + }; + beforeEach(() => { + revision.set(1); + authenticated.set(true); + grants.set([ORGANIZATION_PERMISSION.MAINTENANCE_COST_READ]); + api = { + readReport: vi.fn().mockReturnValue(of(maintenanceReportFixture())), + listDossiers: vi.fn().mockReturnValue(of(empty)), + }; + }); + it.each(['server', 'anonymous', 'manage-only'])( + 'never reads or retains private financial reports in %s context', + (mode) => { + if (mode === 'anonymous') authenticated.set(false); + if (mode === 'manage-only') grants.set([ORGANIZATION_PERMISSION.MAINTENANCE_COST_MANAGE]); + setup(mode === 'server' ? 'server' : 'browser'); + store.readReport({ scope, query }); + store.readDirectory({ scope, query: { page: 1, itemsPerPage: 30 } }); + expect(api.readReport).not.toHaveBeenCalled(); + expect(api.listDossiers).not.toHaveBeenCalled(); + expect(store.reportCallState().data).toBeNull(); + expect(store.dossierEntities()).toEqual([]); + }, + ); + it('uses financial-read permission alone for exact totals and independent named sources', () => { + setup(); + store.readReport({ scope, query }); + expect(store.reportCallState().data?.current.total).toBe('35.000000'); + expect(store.reportCallState().data?.rows.map((row) => row.current.total)).toEqual([ + '10.000000', + '20.000000', + ]); + expect(store.reportCallState().data?.unallocated.current.total).toBe('5.000000'); + store.readDirectory({ scope, query: { page: 2, itemsPerPage: 30, search: 'Entrance' } }); + expect(api.listDossiers).toHaveBeenCalledExactlyOnceWith('org', { + page: 2, + itemsPerPage: 30, + search: 'Entrance', + }); + expect(store.reportCallState().data?.current.total).toBe('35.000000'); + }); + it('does not derive a full-filter total from a sparse allocation page', () => { + setup(); + api.readReport.mockReturnValue( + of(maintenanceReportFixture({ page: 2, totalItems: 61, rows: [] })), + ); + store.readReport({ scope, query: { ...query, page: 2 } }); + expect(store.reportCallState().data?.totalItems).toBe(61); + expect(store.reportCallState().data?.current.total).toBe('35.000000'); + expect(store.reportCallState().data?.rows).toEqual([]); + }); + it('clears old totals while a changed scope is pending and exposes rejection without partial results', () => { + setup(); + store.readReport({ scope, query }); + const pending = new Subject(); + api.readReport.mockReturnValue(pending); + store.readReport({ scope, query: { ...query, equipmentId: 'other' } }); + expect(store.reportCallState().status).toBe('pending'); + expect(store.reportCallState().data).toBeNull(); + pending.error({ + type: 'about:blank', + status: 422, + title: 'Narrow the scope', + detail: 'Too many dossiers.', + }); + expect(store.reportCallState().status).toBe('error'); + expect(store.reportCallState().error?.code).toBe(422); + expect(store.reportCallState().data).toBeNull(); + expect(store.reportQuery()?.equipmentId).toBe('other'); + }); + it.each([ + { organizationId: 'foreign' }, + { from: '2024-01-01' }, + { to: '2024-01-31' }, + { groupBy: 'customer' as const }, + ])( + 'rejects a response whose declared organization or date scope is inconsistent: %s', + (override) => { + setup(); + api.readReport.mockReturnValue(of(maintenanceReportFixture(override))); + store.readReport({ scope, query }); + expect(store.reportCallState().data).toBeNull(); + expect(store.reportCallState().status).toBe('error'); + }, + ); + it('cancels superseded report and directory reads before another organization can render their late replies', () => { + setup(); + const report = new Subject(), + directory = new Subject>(); + api.readReport.mockReturnValue(report); + api.listDossiers.mockReturnValue(directory); + store.readReport({ scope, query }); + store.readDirectory({ scope, query: { page: 1, itemsPerPage: 30 } }); + expect(report.observed).toBe(true); + expect(directory.observed).toBe(true); + store.setScope({ organizationId: 'other', sessionRevision: 1 }); + expect(report.observed).toBe(false); + expect(directory.observed).toBe(false); + report.next(maintenanceReportFixture()); + directory.next(empty); + expect(store.reportCallState().data).toBeNull(); + expect(store.directoryQuery()).toBeNull(); + }); + it('invalidates private totals and names after account replacement or permission revocation', () => { + setup(); + store.readReport({ scope, query }); + TestBed.tick(); + revision.set(2); + TestBed.tick(); + expect(store.scope()).toBeNull(); + expect(store.reportCallState().data).toBeNull(); + store.setScope({ organizationId: 'org', sessionRevision: 2 }); + store.readReport({ scope: { organizationId: 'org', sessionRevision: 2 }, query }); + grants.set([]); + TestBed.tick(); + expect(store.scope()).toBeNull(); + expect(store.dossierEntities()).toEqual([]); + }); + it('normalizes independent directory failures without hiding a valid report', () => { + setup(); + store.readReport({ scope, query }); + api.listDossiers.mockReturnValue( + throwError(() => ({ + type: 'about:blank', + status: 503, + title: 'Unavailable', + detail: 'Try again.', + })), + ); + store.readDirectory({ scope, query: { page: 1, itemsPerPage: 30 } }); + expect(store.directoryCallState().error?.code).toBe(503); + expect(store.reportCallState().status).toBe('success'); + }); +}); diff --git a/src/app/features/organization/features/maintenance-costs/ui/components/maintenance-cost-facts/index.ts b/src/app/features/organization/features/maintenance-costs/ui/components/maintenance-cost-facts/index.ts new file mode 100644 index 000000000..b2c8dae62 --- /dev/null +++ b/src/app/features/organization/features/maintenance-costs/ui/components/maintenance-cost-facts/index.ts @@ -0,0 +1 @@ +export { MaintenanceCostFacts } from './maintenance-cost-facts.component'; diff --git a/src/app/features/organization/features/maintenance-costs/ui/components/maintenance-cost-facts/maintenance-cost-facts.component.html b/src/app/features/organization/features/maintenance-costs/ui/components/maintenance-cost-facts/maintenance-cost-facts.component.html new file mode 100644 index 000000000..0a13ed77d --- /dev/null +++ b/src/app/features/organization/features/maintenance-costs/ui/components/maintenance-cost-facts/maintenance-cost-facts.component.html @@ -0,0 +1,121 @@ +@if (items().length) { +
+
+ @for (item of items(); track item.id) { +
+
+

{{ item.description }}

+ {{ kindLabel(item.kind) }} + @if (item.correctionOf) { + Traceable adjustment + } +
+
+
+ Occurred on +
+
{{ item.occurredAt | appOrgDate: 'datetime' }}
+
+
+
+ Exact amount +
+
+ {{ money(item.amount, item.currency, locale) }} +
+
+
+ @if (canAdjust() && originalExpense(item)) { + + } +
+
+ } +
+ +
+} @else { +
+
+

No cost contributions recorded

+

+ Recorded work time, material use and external expenses will appear here. +

+
+
+} diff --git a/src/app/features/organization/features/maintenance-costs/ui/components/maintenance-cost-facts/maintenance-cost-facts.component.ts b/src/app/features/organization/features/maintenance-costs/ui/components/maintenance-cost-facts/maintenance-cost-facts.component.ts new file mode 100644 index 000000000..ec7f9b7b9 --- /dev/null +++ b/src/app/features/organization/features/maintenance-costs/ui/components/maintenance-cost-facts/maintenance-cost-facts.component.ts @@ -0,0 +1,161 @@ +import { + ChangeDetectionStrategy, + Component, + inject, + input, + LOCALE_ID, + output, + type InputSignal, + type OutputEmitterRef, +} from '@angular/core'; +import type { MaintenanceCostItem } from '@features/organization/features/maintenance-costs/models'; +import { formatMaintenanceAmount } from '@features/organization/features/maintenance-costs/utils'; +import { OrgDatePipe } from '@shared/regional-format'; +import { HlmBadge } from '@shared/ui/badge'; +import { HlmButton } from '@shared/ui/button'; +import { HlmCardImports } from '@shared/ui/card'; +import { HlmEmptyImports } from '@shared/ui/empty'; +import { HlmTableImports } from '@shared/ui/table'; + +/** + * Class MaintenanceCostFacts + * + * @description + * Presents exact private contributions and their traceable adjustments without owning transport or + * navigation. + */ +@Component({ + selector: 'app-maintenance-cost-facts', + templateUrl: './maintenance-cost-facts.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + OrgDatePipe, + HlmBadge, + HlmButton, + ...HlmCardImports, + ...HlmTableImports, + ...HlmEmptyImports, + ], +}) +export class MaintenanceCostFacts { + /** + * Property items + * @readonly + * + * @description + * Current contributions or an immutable frozen list of contributions. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly items: InputSignal = input< + readonly MaintenanceCostItem[] + >([]); + /** + * Property canAdjust + * @readonly + * + * @description + * Finance management permission permits motivated adjustments of original expenses. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly canAdjust: InputSignal = input(false); + /** + * Property pending + * @readonly + * + * @description + * An accepted write is awaiting acknowledgement. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly pending: InputSignal = input(false); + /** + * Property adjusted + * @readonly + * + * @description + * Original expense selected for a traceable correcting declaration. + * + * @access public + * @since unreleased + * + * @type {OutputEmitterRef} + */ + public readonly adjusted: OutputEmitterRef = output(); + /** + * Property locale + * @readonly + * + * @description + * Interface locale for exact decimal grouping. + * + * @access protected + * @since unreleased + * + * @type {string} + */ + protected readonly locale: string = inject(LOCALE_ID); + /** + * Property money + * @readonly + * + * @description + * Exact string formatter keeping unknown values distinct from known zero. + * + * @access protected + * @since unreleased + * + * @type {typeof formatMaintenanceAmount} + */ + protected readonly money: typeof formatMaintenanceAmount = formatMaintenanceAmount; + /** + * Method originalExpense + * + * @description + * Identifies an original expense eligible for a linked correction rather than correcting a + * correction. + * + * @access protected + * @since unreleased + * + * @param {MaintenanceCostItem} item - Original operational contribution. + * + * @returns {boolean} Validated financial contract result. + */ + protected originalExpense(item: MaintenanceCostItem): boolean { + return item.kind === 'expense' && !item.correctionOf?.startsWith('expense:'); + } + /** + * Method kindLabel + * + * @description + * Returns the localized operational contribution category. + * + * @access protected + * @since unreleased + * + * @param {MaintenanceCostItem['kind']} kind - Operational contribution category. + * + * @returns {string} Validated financial contract result. + */ + protected kindLabel(kind: MaintenanceCostItem['kind']): string { + switch (kind) { + case 'time': + return $localize`:@@maintenanceCost.kind.time:Work time`; + case 'material': + return $localize`:@@maintenanceCost.kind.material:Materials`; + case 'expense': + return $localize`:@@maintenanceCost.kind.expense:External expense`; + } + } +} diff --git a/src/app/features/organization/features/maintenance-costs/ui/components/maintenance-cost-settings/index.ts b/src/app/features/organization/features/maintenance-costs/ui/components/maintenance-cost-settings/index.ts new file mode 100644 index 000000000..989204ba9 --- /dev/null +++ b/src/app/features/organization/features/maintenance-costs/ui/components/maintenance-cost-settings/index.ts @@ -0,0 +1 @@ +export { MaintenanceCostSettings } from './maintenance-cost-settings.component'; diff --git a/src/app/features/organization/features/maintenance-costs/ui/components/maintenance-cost-settings/maintenance-cost-settings.component.html b/src/app/features/organization/features/maintenance-costs/ui/components/maintenance-cost-settings/maintenance-cost-settings.component.html new file mode 100644 index 000000000..9e9c6bf56 --- /dev/null +++ b/src/app/features/organization/features/maintenance-costs/ui/components/maintenance-cost-settings/maintenance-cost-settings.component.html @@ -0,0 +1,214 @@ +
+
+
+

Organization currency

+

+ Operational costs share one currency. It is locked after the first financial record. +

+
+
+ @if (currencyPending()) { +
+ Loading currency… +
+ } + @if (currencyError(); as failure) { +
+
+ } + @if (currency(); as current) { +
+
+ Current currency +
+
+ {{ current.currency }} + @if (current.locked) { + Locked + } +
+
+ @if (current.locked) { +

+ Financial records already use this currency. It can no longer be changed. +

+ } + } @else if (!currencyPending() && !currencyError()) { +

+ No currency has been set. +

+ } + @if (canManage()) { + + } +
+
+
+
+

Hourly rate history

+

+ Rates are dated and immutable. Add a new rate when an amount changes. +

+
+
+ @if (ratesPending()) { +
+ Loading rate history… +
+ } + @if (ratesError(); as failure) { +
+
+ } + @if (rates().length > 0) { +
+ @for (rate of rates(); track rate.id) { +
+
+

{{ memberName(rate.memberId) }}

+
+
+
+ Hourly amount +
+
{{ rateAmount(rate) }}
+
+
+
+ Effective from +
+
{{ rate.effectiveFrom | appOrgDate: 'dateOnly' }}
+
+
+
+
+ } +
+ + } @else if (!ratesPending() && !ratesError()) { + + + +

+ No hourly rates yet +

+

+ No hourly rates have been recorded for this organization. +

+
+
+ } + @if (rateTotal() > 0) { + + } + @if (canManage()) { + + } +
+
+
diff --git a/src/app/features/organization/features/maintenance-costs/ui/components/maintenance-cost-settings/maintenance-cost-settings.component.ts b/src/app/features/organization/features/maintenance-costs/ui/components/maintenance-cost-settings/maintenance-cost-settings.component.ts new file mode 100644 index 000000000..509716618 --- /dev/null +++ b/src/app/features/organization/features/maintenance-costs/ui/components/maintenance-cost-settings/maintenance-cost-settings.component.ts @@ -0,0 +1,550 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + inject, + input, + LOCALE_ID, + output, + type InputSignal, + type OutputEmitterRef, + type Signal, +} from '@angular/core'; +import { NgIcon, provideIcons } from '@ng-icons/core'; +import { lucideCircleAlert } from '@ng-icons/lucide'; +import type { StoreError } from '@core/request-state'; +import type { + CreateMaintenanceRateInput, + MaintenanceCurrencyOutput, + MaintenanceRateOutput, +} from '@features/organization/features/maintenance-costs/models'; +import { MaintenanceCurrencyForm } from '@features/organization/features/maintenance-costs/ui/forms/maintenance-currency-form'; +import { MaintenanceRateForm } from '@features/organization/features/maintenance-costs/ui/forms/maintenance-rate-form'; +import { formatMaintenanceAmount } from '@features/organization/features/maintenance-costs/utils'; +import type { MemberSelectOption } from '@features/organization/models'; +import { CollectionPagination } from '@shared/collection-pagination'; +import { OrgDatePipe } from '@shared/regional-format'; +import { StateIllustration } from '@shared/state-illustration'; +import { HlmAlertImports } from '@shared/ui/alert'; +import { HlmBadge } from '@shared/ui/badge'; +import { HlmButton } from '@shared/ui/button'; +import { HlmCardImports } from '@shared/ui/card'; +import { HlmEmptyImports } from '@shared/ui/empty'; +import { HlmSpinner } from '@shared/ui/spinner'; +import { HlmTableImports } from '@shared/ui/table'; + +/** + * Class MaintenanceCostSettings + * @class MaintenanceCostSettings + * + * @description + * Presents the currency lock and immutable dated rates; the parent owns permissions and writes. + */ +@Component({ + selector: 'app-maintenance-cost-settings', + templateUrl: './maintenance-cost-settings.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + MaintenanceCurrencyForm, + MaintenanceRateForm, + NgIcon, + CollectionPagination, + OrgDatePipe, + StateIllustration, + HlmBadge, + HlmButton, + HlmSpinner, + ...HlmAlertImports, + ...HlmCardImports, + ...HlmEmptyImports, + ...HlmTableImports, + ], + providers: [provideIcons({ lucideCircleAlert })], +}) +export class MaintenanceCostSettings { + //#region Properties + /** + * Property locale + * @readonly + * + * @description + * Interface locale used to group exact financial strings without numeric conversion. + * + * @access private + * @since 2026-10-06 + * + * @type {string} + */ + private readonly locale: string = inject(LOCALE_ID); + + /** + * Property currency + * @readonly + * + * @description + * Last confirmed organization currency and lock. + * + * @access public + * @since 2026-10-06 + * + * @type {InputSignal} + */ + public readonly currency: InputSignal = + input(null); + + /** + * Property currencyPending + * @readonly + * + * @description + * Independent currency read is in flight. + * + * @access public + * @since 2026-10-06 + * + * @type {InputSignal} + */ + public readonly currencyPending: InputSignal = input(false); + + /** + * Property currencyError + * @readonly + * + * @description + * Currency read failure remains locally retryable. + * + * @access public + * @since 2026-10-06 + * + * @type {InputSignal} + */ + public readonly currencyError: InputSignal = input(null); + + /** + * Property currencySaving + * @readonly + * + * @description + * Currency write locks the edit form independently of rate writes. + * + * @access public + * @since 2026-10-06 + * + * @type {InputSignal} + */ + public readonly currencySaving: InputSignal = input(false); + + /** + * Property currencyWriteError + * @readonly + * + * @description + * Rejected currency save is shown beside its retained draft. + * + * @access public + * @since 2026-10-06 + * + * @type {InputSignal} + */ + public readonly currencyWriteError: InputSignal = input( + null, + ); + + /** + * Property rates + * @readonly + * + * @description + * Confirmed immutable rows in the current server page. + * + * @access public + * @since 2026-10-06 + * + * @type {InputSignal} + */ + public readonly rates: InputSignal = input< + readonly MaintenanceRateOutput[] + >([]); + + /** + * Property ratesPending + * @readonly + * + * @description + * Rate history read retains existing rows during refresh. + * + * @access public + * @since 2026-10-06 + * + * @type {InputSignal} + */ + public readonly ratesPending: InputSignal = input(false); + + /** + * Property ratesError + * @readonly + * + * @description + * Rate history read failure has its own retry action. + * + * @access public + * @since 2026-10-06 + * + * @type {InputSignal} + */ + public readonly ratesError: InputSignal = input(null); + + /** + * Property ratesSaving + * @readonly + * + * @description + * Rate submission freezes the currently submitted draft. + * + * @access public + * @since 2026-10-06 + * + * @type {InputSignal} + */ + public readonly ratesSaving: InputSignal = input(false); + + /** + * Property ratesWriteError + * @readonly + * + * @description + * Rate write failure retains the original form fields and declaration. + * + * @access public + * @since 2026-10-06 + * + * @type {InputSignal} + */ + public readonly ratesWriteError: InputSignal = input(null); + + /** + * Property canManage + * @readonly + * + * @description + * Effective financial management permission; never derived from organization profile. + * + * @access public + * @since 2026-10-06 + * + * @type {InputSignal} + */ + public readonly canManage: InputSignal = input(false); + + /** + * Property members + * @readonly + * + * @description + * Authorized human member options supplied by the page. + * + * @access public + * @since 2026-10-06 + * + * @type {InputSignal} + */ + public readonly members: InputSignal = input< + readonly MemberSelectOption[] + >([]); + + /** + * Property membersLoading + * @readonly + * + * @description + * Directory loading prevents creating a rate with stale options. + * + * @access public + * @since 2026-10-06 + * + * @type {InputSignal} + */ + public readonly membersLoading: InputSignal = input(false); + + /** + * Property membersAllowed + * @readonly + * + * @description + * Directory permission gates names and rate member selection. + * + * @access public + * @since 2026-10-06 + * + * @type {InputSignal} + */ + public readonly membersAllowed: InputSignal = input(false); + + /** + * Property ratePage + * @readonly + * + * @description + * Current one-based server rate page. + * + * @access public + * @since 2026-10-06 + * + * @type {InputSignal} + */ + public readonly ratePage: InputSignal = input(1); + + /** + * Property rateTotal + * @readonly + * + * @description + * Exact server total rather than current page length. + * + * @access public + * @since 2026-10-06 + * + * @type {InputSignal} + */ + public readonly rateTotal: InputSignal = input(0); + + /** + * Property ratePageSize + * @readonly + * + * @description + * Server page size supplied by the page; the standard request size is thirty rows. + * + * @access public + * @since 2026-10-06 + * + * @type {InputSignal} + */ + public readonly ratePageSize: InputSignal = input(30); + + /** + * Property uncertainRate + * @readonly + * + * @description + * Only the original stored declaration may be replayed until its outcome is confirmed. + * + * @access public + * @since 2026-10-06 + * + * @type {InputSignal} + */ + public readonly uncertainRate: InputSignal = input(false); + + /** + * Property currencyResetToken + * @readonly + * + * @description + * Explicit confirmed currency-save reset or organization context replacement. + * + * @access public + * @since 2026-10-06 + * + * @type {InputSignal} + */ + public readonly currencyResetToken: InputSignal = input(0); + + /** + * Property rateResetToken + * @readonly + * + * @description + * Explicit confirmed rate-save reset or organization context replacement. + * + * @access public + * @since 2026-10-06 + * + * @type {InputSignal} + */ + public readonly rateResetToken: InputSignal = input(0); + + /** + * Property currencySubmitted + * @readonly + * + * @description + * Forwards an exact uppercase currency code for an authorized write. + * + * @access public + * @since 2026-10-06 + * + * @type {OutputEmitterRef} + */ + public readonly currencySubmitted: OutputEmitterRef = output(); + + /** + * Property rateSubmitted + * @readonly + * + * @description + * Forwards rate data without generating the store-owned idempotency key. + * + * @access public + * @since 2026-10-06 + * + * @type {OutputEmitterRef>} + */ + public readonly rateSubmitted: OutputEmitterRef> = + output>(); + + /** + * Property reloadCurrency + * @readonly + * + * @description + * Requests only a currency read refresh. + * + * @access public + * @since 2026-10-06 + * + * @type {OutputEmitterRef} + */ + public readonly reloadCurrency: OutputEmitterRef = output(); + + /** + * Property reloadRates + * @readonly + * + * @description + * Requests only a rate history refresh. + * + * @access public + * @since 2026-10-06 + * + * @type {OutputEmitterRef} + */ + public readonly reloadRates: OutputEmitterRef = output(); + + /** + * Property ratePageChanged + * @readonly + * + * @description + * Requests a bounded server page while preserving its total and query scope. + * + * @access public + * @since 2026-10-06 + * + * @type {OutputEmitterRef} + */ + public readonly ratePageChanged: OutputEmitterRef = output(); + + /** + * Property retryRate + * @readonly + * + * @description + * Requests replay of the original rate identity and payload. + * + * @access public + * @since 2026-10-06 + * + * @type {OutputEmitterRef} + */ + public readonly retryRate: OutputEmitterRef = output(); + + /** + * Property ratePageCount + * @readonly + * + * @description + * Computes page count from the exact server total and explicit page size. + * + * @access protected + * @since 2026-10-06 + * + * @type {Signal} + */ + protected readonly ratePageCount: Signal = computed(() => + Math.max(1, Math.ceil(this.rateTotal() / Math.max(1, this.ratePageSize()))), + ); + + /** + * Property readErrorFallback + * @readonly + * + * @description + * Localized retry guidance when a read failure contains no readable message. + * + * @access protected + * @since 2026-10-06 + * + * @type {string} + */ + protected readonly readErrorFallback: string = $localize`:@@maintenanceCost.settings.readError:Try loading this information again.`; + //#endregion + + //#region Methods + /** + * Method memberName + * @method memberName + * + * @description + * Resolves permitted human names without rendering raw member UUIDs as fallback. + * + * @access protected + * @since 2026-10-06 + * + * @param {string} memberId - Historical member identity. + * + * @returns {string} Human name or neutral inaccessible-member label. + */ + protected memberName(memberId: string): string { + return ( + (this.membersAllowed() + ? this.members().find((member: MemberSelectOption) => member.value === memberId) + ?.displayName + : null) || $localize`:@@maintenanceCost.rate.memberUnavailable:Member details unavailable` + ); + } + + /** + * Method changeRatePage + * @method changeRatePage + * + * @description + * Ignores paging during a pending read and validates the requested server range. + * + * @access protected + * @since 2026-10-06 + * + * @param {number} page - Requested one-based page. + * + * @returns {void} + */ + protected changeRatePage(page: number): void { + if ( + !this.ratesPending() && + Number.isInteger(page) && + page >= 1 && + page <= this.ratePageCount() && + page !== this.ratePage() + ) + this.ratePageChanged.emit(page); + } + + /** + * Method rateAmount + * @method rateAmount + * + * @description + * Formats a historical amount without losing any of its six supported decimal places. + * + * @access protected + * @since 2026-10-06 + * + * @param {MaintenanceRateOutput} rate - Confirmed immutable rate. + * + * @returns {string} Exact localized amount and currency. + */ + protected rateAmount(rate: MaintenanceRateOutput): string { + return formatMaintenanceAmount(rate.hourlyAmount, rate.currency, this.locale); + } + //#endregion +} diff --git a/src/app/features/organization/features/maintenance-costs/ui/components/maintenance-cost-settings/testing/maintenance-cost-settings.component.spec.ts b/src/app/features/organization/features/maintenance-costs/ui/components/maintenance-cost-settings/testing/maintenance-cost-settings.component.spec.ts new file mode 100644 index 000000000..912c8ff67 --- /dev/null +++ b/src/app/features/organization/features/maintenance-costs/ui/components/maintenance-cost-settings/testing/maintenance-cost-settings.component.spec.ts @@ -0,0 +1,180 @@ +import { signal } from '@angular/core'; +import { TestBed, type ComponentFixture } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { provideRouter } from '@angular/router'; +import { toStoreError } from '@core/request-state'; +import { THEME_PORT, type ThemePort } from '@core/theme'; +import type { MaintenanceRateOutput } from '@features/organization/features/maintenance-costs/models'; +import { MaintenanceCurrencyForm } from '@features/organization/features/maintenance-costs/ui/forms/maintenance-currency-form'; +import { MaintenanceRateForm } from '@features/organization/features/maintenance-costs/ui/forms/maintenance-rate-form'; +import type { MemberSelectOption } from '@features/organization/models'; +import { CollectionPagination } from '@shared/collection-pagination'; +import { MaintenanceCostSettings } from '../maintenance-cost-settings.component'; + +describe('MaintenanceCostSettings', () => { + let fixture: ComponentFixture; + const rate: MaintenanceRateOutput = { + '@id': '/maintenance-cost/rates/rate-id', + '@type': 'MaintenanceRate', + id: 'rate-id', + memberId: 'hidden-member-uuid', + hourlyAmount: '9007199254740993.123456', + currency: 'EUR', + effectiveFrom: '2026-10-25', + replayed: false, + }; + const member: MemberSelectOption = { + value: rate.memberId, + label: 'Alex Martin', + displayName: 'Alex Martin', + roleLabel: 'Technician', + avatarUrl: null, + initials: 'AM', + }; + const root = (): HTMLElement => fixture.nativeElement as HTMLElement; + + beforeEach(async () => { + const theme: ThemePort = { + theme: signal('light'), + resolvedTheme: signal('light'), + setTheme: vi.fn(), + }; + TestBed.configureTestingModule({ + providers: [provideRouter([]), { provide: THEME_PORT, useValue: theme }], + }); + fixture = TestBed.createComponent(MaintenanceCostSettings); + fixture.componentRef.setInput('currency', { + organizationId: 'organization', + currency: 'EUR', + locked: false, + }); + fixture.componentRef.setInput('rates', [rate]); + fixture.componentRef.setInput('rateTotal', 1); + fixture.componentRef.setInput('members', [member]); + await fixture.whenStable(); + }); + + it('renders immutable history with exact decimals and no raw member UUID fallback', () => { + expect(root().querySelector('tbody')?.textContent).toContain( + '9,007,199,254,740,993.123456 EUR', + ); + expect(root().querySelector('tbody')?.textContent).toContain('Member details unavailable'); + expect(root().textContent).not.toContain(rate.memberId); + expect(root().querySelector('tbody button')).toBeNull(); + expect(root().querySelector('form')).toBeNull(); + }); + + it('shows human names only when directory permission is granted', async () => { + fixture.componentRef.setInput('membersAllowed', true); + await fixture.whenStable(); + expect(root().querySelector('tbody')?.textContent).toContain('Alex Martin'); + fixture.componentRef.setInput('membersAllowed', false); + await fixture.whenStable(); + expect(root().textContent).not.toContain('Alex Martin'); + expect(root().textContent).not.toContain(rate.memberId); + }); + + it('keeps locked currency read-only even with management permission', async () => { + fixture.componentRef.setInput('canManage', true); + fixture.componentRef.setInput('currency', { + organizationId: 'organization', + currency: 'EUR', + locked: true, + }); + await fixture.whenStable(); + expect(root().querySelector('[data-testid="maintenance-currency-form"]')).toBeNull(); + expect(root().textContent).toContain('Locked'); + expect(root().textContent).toContain('can no longer be changed'); + }); + + it('forwards typed form payloads and original retry intent', async () => { + fixture.componentRef.setInput('canManage', true); + fixture.componentRef.setInput('membersAllowed', true); + await fixture.whenStable(); + const currencySubmitted = vi.fn(); + const rateSubmitted = vi.fn(); + const retryRate = vi.fn(); + fixture.componentInstance.currencySubmitted.subscribe(currencySubmitted); + fixture.componentInstance.rateSubmitted.subscribe(rateSubmitted); + fixture.componentInstance.retryRate.subscribe(retryRate); + const currencyForm: MaintenanceCurrencyForm = fixture.debugElement.query( + By.directive(MaintenanceCurrencyForm), + ).componentInstance; + const rateForm: MaintenanceRateForm = fixture.debugElement.query( + By.directive(MaintenanceRateForm), + ).componentInstance; + currencyForm.submitted.emit('USD'); + rateForm.submitted.emit({ + memberId: member.value, + hourlyAmount: '12.123456', + effectiveFrom: '2026-10-25', + }); + rateForm.retry.emit(); + expect(currencySubmitted).toHaveBeenCalledExactlyOnceWith('USD'); + expect(rateSubmitted).toHaveBeenCalledExactlyOnceWith({ + memberId: member.value, + hourlyAmount: '12.123456', + effectiveFrom: '2026-10-25', + }); + expect(retryRate).toHaveBeenCalledExactlyOnceWith(undefined); + }); + + it('keeps currency and rate load retries independent', async () => { + const currencyReload = vi.fn(); + const rateReload = vi.fn(); + fixture.componentInstance.reloadCurrency.subscribe(currencyReload); + fixture.componentInstance.reloadRates.subscribe(rateReload); + fixture.componentRef.setInput('currencyError', toStoreError(new Error('Currency unavailable'))); + fixture.componentRef.setInput('ratesError', toStoreError(new Error('Rates unavailable'))); + await fixture.whenStable(); + const buttons: HTMLButtonElement[] = Array.from(root().querySelectorAll('button')); + buttons + .find((button: HTMLButtonElement) => button.textContent?.includes('Reload currency')) + ?.click(); + expect(currencyReload).toHaveBeenCalledTimes(1); + expect(rateReload).not.toHaveBeenCalled(); + buttons + .find((button: HTMLButtonElement) => button.textContent?.includes('Reload rate history')) + ?.click(); + expect(rateReload).toHaveBeenCalledTimes(1); + expect(root().querySelectorAll('[role="alert"]')).toHaveLength(2); + }); + + it('pages from the server total and rejects out-of-range or in-flight page intents', async () => { + fixture.componentRef.setInput('rateTotal', 65); + fixture.componentRef.setInput('ratePageSize', 30); + await fixture.whenStable(); + const changed = vi.fn(); + fixture.componentInstance.ratePageChanged.subscribe(changed); + const pagination: CollectionPagination = fixture.debugElement.query( + By.directive(CollectionPagination), + ).componentInstance; + expect(pagination.pageCount()).toBe(3); + pagination.pageChanged.emit(2); + expect(changed).toHaveBeenCalledExactlyOnceWith(2); + pagination.pageChanged.emit(0); + pagination.pageChanged.emit(4); + fixture.componentRef.setInput('ratesPending', true); + await fixture.whenStable(); + pagination.pageChanged.emit(3); + expect(changed).toHaveBeenCalledTimes(1); + expect(root().querySelector('tbody')?.textContent).toContain( + '9,007,199,254,740,993.123456 EUR', + ); + }); + + it('distinguishes empty history from loading and read failures', async () => { + fixture.componentRef.setInput('rates', []); + fixture.componentRef.setInput('rateTotal', 0); + fixture.componentRef.setInput('ratesPending', true); + await fixture.whenStable(); + expect(root().querySelector('hlm-empty')).toBeNull(); + fixture.componentRef.setInput('ratesPending', false); + fixture.componentRef.setInput('ratesError', toStoreError(new Error('Unavailable'))); + await fixture.whenStable(); + expect(root().querySelector('hlm-empty')).toBeNull(); + fixture.componentRef.setInput('ratesError', null); + await fixture.whenStable(); + expect(root().querySelector('hlm-empty')?.textContent).toContain('No hourly rates yet'); + }); +}); diff --git a/src/app/features/organization/features/maintenance-costs/ui/dataviews/maintenance-financial-directory/index.ts b/src/app/features/organization/features/maintenance-costs/ui/dataviews/maintenance-financial-directory/index.ts new file mode 100644 index 000000000..2ad56f3f8 --- /dev/null +++ b/src/app/features/organization/features/maintenance-costs/ui/dataviews/maintenance-financial-directory/index.ts @@ -0,0 +1 @@ +export { MaintenanceFinancialDirectory } from './maintenance-financial-directory.component'; diff --git a/src/app/features/organization/features/maintenance-costs/ui/dataviews/maintenance-financial-directory/maintenance-financial-directory.component.html b/src/app/features/organization/features/maintenance-costs/ui/dataviews/maintenance-financial-directory/maintenance-financial-directory.component.html new file mode 100644 index 000000000..9813f9a75 --- /dev/null +++ b/src/app/features/organization/features/maintenance-costs/ui/dataviews/maintenance-financial-directory/maintenance-financial-directory.component.html @@ -0,0 +1,254 @@ +
+
+

+ Financial dossiers +

+

+ Find a work reference or name, open its private financial dossier, or narrow the economic + report using its named scope. +

+
+
+
+ + @if (searchForm.search().touched()) { + @for (error of searchForm.search().errors(); track error) { + {{ error.message }} + } + } +
+
+ + @if (draft().search.trim()) { + + } +
+
+ @if (pending()) { +

+ Reading financial dossiers +

+ } + @if (error(); as failure) { + @if (items().length) { +
+
+ } @else if (!pending()) { +
+
+
+
+

+ Financial dossiers could not be loaded +

+

{{ failure.message }}

+
+
+ +
+
+ } + } + @if (items().length) { +
+ @for (entry of items(); track entry.id; let last = $last) { +
+
+
+

+ {{ entry.number }} · {{ entry.name }} +

+ {{ + snapshotLabel(entry.snapshotState) + }} +
+ @if (!entry.identityComplete) { +

+ Historical identity is incomplete. Only available named filters can be selected. +

+ } + @if (entry.publishedAt) { +

+ Published on + {{ entry.publishedAt | appOrgDate: 'datetime' : regionalFormatting() }} +

+ } +
+ @if (entry.site; as site) { + + } + @if (entry.customer; as customer) { + + } +
+ @if (entry.equipment.length) { +
+ @for (equipment of entry.equipment; track equipmentAssignmentKey(equipment)) { + + } +
+ } +
+ +
+ @if (!last) { + + } + } +
+ } @else if (!pending() && !error()) { +
+
+ @if (search().trim()) { + + } @else { + + } +

+ No financial dossiers in this scope +

+

+ Try another work reference, date window or named filter. +

+
+ @if (search().trim()) { +
+ +
+ } +
+ } + +
diff --git a/src/app/features/organization/features/maintenance-costs/ui/dataviews/maintenance-financial-directory/maintenance-financial-directory.component.ts b/src/app/features/organization/features/maintenance-costs/ui/dataviews/maintenance-financial-directory/maintenance-financial-directory.component.ts new file mode 100644 index 000000000..d1c0cd0f5 --- /dev/null +++ b/src/app/features/organization/features/maintenance-costs/ui/dataviews/maintenance-financial-directory/maintenance-financial-directory.component.ts @@ -0,0 +1,542 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + input, + output, + signal, + untracked, + type InputSignal, + type OutputEmitterRef, + type Signal, + type WritableSignal, +} from '@angular/core'; +import { form, FormField, FormRoot, maxLength, type FieldTree } from '@angular/forms/signals'; +import { RouterLink } from '@angular/router'; +import { NgIcon, provideIcons } from '@ng-icons/core'; +import { lucideCircleAlert } from '@ng-icons/lucide'; +import type { StoreError } from '@core/request-state'; +import type { + MaintenanceFinancialDossierOutput, + MaintenanceFinancialEquipmentIdentity, + MaintenanceReportScopeSelection, +} from '@features/organization/features/maintenance-costs/models'; +import { CollectionPagination } from '@shared/collection-pagination'; +import { + OrgDatePipe, + DEFAULT_REGIONAL_FORMAT_SETTINGS, + type RegionalFormatSettings, +} from '@shared/regional-format'; +import { ResourceIllustration } from '@shared/resource-illustration'; +import { StateIllustration } from '@shared/state-illustration'; +import { HlmAlertImports } from '@shared/ui/alert'; +import { HlmBadge } from '@shared/ui/badge'; +import { HlmButton } from '@shared/ui/button'; +import { HlmEmptyImports } from '@shared/ui/empty'; +import { HlmFieldImports } from '@shared/ui/field'; +import { HlmInput } from '@shared/ui/input'; +import { HlmItemImports } from '@shared/ui/item'; +import { HlmSpinner } from '@shared/ui/spinner'; + +/** + * Interface FinancialSearchDraft + * @interface FinancialSearchDraft + * + * @description + * Local search text is independent of private server directory replies. + */ +interface FinancialSearchDraft { + /** + * Property search + * + * @description + * Work reference or name, limited to the server's 160-character bound. + * + * @type {string} + */ + search: string; +} + +/** + * Class MaintenanceFinancialDirectory + * @class MaintenanceFinancialDirectory + * + * @description + * Renders a bounded named financial directory and emits filter intent without reading ordinary + * equipment or customer APIs. + */ +@Component({ + selector: 'app-maintenance-financial-directory', + templateUrl: './maintenance-financial-directory.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + FormField, + FormRoot, + RouterLink, + NgIcon, + CollectionPagination, + OrgDatePipe, + ResourceIllustration, + StateIllustration, + HlmBadge, + HlmButton, + HlmInput, + HlmSpinner, + ...HlmAlertImports, + ...HlmEmptyImports, + ...HlmFieldImports, + ...HlmItemImports, + ], + providers: [provideIcons({ lucideCircleAlert })], +}) +export class MaintenanceFinancialDirectory { + //#region Properties + /** + * Property regionalFormatting + * @readonly + * + * @description + * Explicit organization date display settings, independent of the UTC report selection window. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly regionalFormatting: InputSignal = + input(DEFAULT_REGIONAL_FORMAT_SETTINGS); + + /** + * Property organizationId + * @readonly + * + * @description + * Owning organization used solely for private financial dossier links. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly organizationId: InputSignal = input.required(); + + /** + * Property items + * @readonly + * + * @description + * Current bounded server page of minimal financial identities, never a complete resource + * catalogue. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly items: InputSignal = input< + readonly MaintenanceFinancialDossierOutput[] + >([]); + + /** + * Property page + * @readonly + * + * @description + * Actual one-based financial directory page. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly page: InputSignal = input(1); + + /** + * Property itemsPerPage + * @readonly + * + * @description + * Server directory page size, independent of report allocation pagination. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly itemsPerPage: InputSignal = input(25); + + /** + * Property totalItems + * @readonly + * + * @description + * Full matching directory count supplied by the server, including unloaded pages. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly totalItems: InputSignal = input(0); + + /** + * Property pending + * @readonly + * + * @description + * Directory read state keeps received rows visible while a newer search is in progress. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly pending: InputSignal = input(false); + + /** + * Property error + * @readonly + * + * @description + * Normalized directory read failure; it never resets the search draft. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly error: InputSignal = input(null); + + /** + * Property search + * @readonly + * + * @description + * Initial or explicitly reset server search; ordinary reads do not replace edited text. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly search: InputSignal = input(''); + + /** + * Property scope + * @readonly + * + * @description + * Organization and authenticated-session identity that resets private search text on replacement. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly scope: InputSignal = input.required(); + + /** + * Property resetToken + * @readonly + * + * @description + * Explicit directory reset supplied by the page, separate from ordinary response refreshes. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly resetToken: InputSignal = input(0); + + /** + * Property searchSubmitted + * @readonly + * + * @description + * Emits normalized bounded search intent; the page restarts directory pagination at one. + * + * @access public + * @since unreleased + * + * @type {OutputEmitterRef} + */ + public readonly searchSubmitted: OutputEmitterRef = output(); + + /** + * Property pageChanged + * @readonly + * + * @description + * Requests another server directory page without downloading a full option catalogue. + * + * @access public + * @since unreleased + * + * @type {OutputEmitterRef} + */ + public readonly pageChanged: OutputEmitterRef = output(); + + /** + * Property pageSizeChanged + * @readonly + * + * @description + * Requests a new bounded directory page size. + * + * @access public + * @since unreleased + * + * @type {OutputEmitterRef} + */ + public readonly pageSizeChanged: OutputEmitterRef = output(); + + /** + * Property scopeSelected + * @readonly + * + * @description + * Emits the complete compatible named scope selection rather than merging unrelated filters. + * + * @access public + * @since unreleased + * + * @type {OutputEmitterRef} + */ + public readonly scopeSelected: OutputEmitterRef = + output(); + + /** + * Property retryRequested + * @readonly + * + * @description + * Requests a fresh directory read while retaining scope and local search text. + * + * @access public + * @since unreleased + * + * @type {OutputEmitterRef} + */ + public readonly retryRequested: OutputEmitterRef = output(); + + /** + * Property pageCount + * @readonly + * + * @description + * Number of directory navigation pages derived solely from server count and page size. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly pageCount: Signal = computed(() => + Math.max(1, Math.ceil(this.totalItems() / Math.max(1, this.itemsPerPage()))), + ); + + /** + * Property draft + * @readonly + * + * @description + * Local name/reference search survives pending, failed and ordinary successful reads. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal} + */ + protected readonly draft: WritableSignal = signal({ search: '' }); + + /** + * Property searchForm + * @readonly + * + * @description + * Native Signal Form enforces the directory's 160-character search bound. + * + * @access protected + * @since unreleased + * + * @type {FieldTree} + */ + protected readonly searchForm: FieldTree = form(this.draft, (path) => { + maxLength(path.search, 160, { + message: $localize`:@@maintenanceCost.directory.searchTooLong:Use at most 160 characters for the work reference or name.`, + }); + }); + + /** + * Property seededScope + * + * @description + * Last explicit reset identity prevents response-driven loss of search edits. + * + * @access private + * @since unreleased + * + * @type {string} + */ + private seededScope: string = ''; + //#endregion + + //#region Constructor + /** + * Constructor + * @constructor + * + * @description + * Adopts search text only at initialization or an explicit organization/session reset. + * + * @access public + * @since unreleased + */ + public constructor() { + effect(() => { + const identity = `${this.scope()}/${this.resetToken()}`, + search = this.search(); + if (identity === this.seededScope) return; + this.seededScope = identity; + untracked(() => this.searchForm().reset({ search })); + }); + } + //#endregion + + //#region Methods + /** + * Method submit + * @method submit + * + * @description + * Emits an explicitly submitted bounded search while leaving asynchronous reads to the page. + * + * @access protected + * @since unreleased + * + * @param {Event} event - Native search form submission event. + * + * @returns {void} No return value. + */ + protected submit(event: Event): void { + event.preventDefault(); + this.searchForm().markAsTouched(); + if (this.searchForm().invalid() || this.pending()) return; + this.searchSubmitted.emit(this.draft().search.trim()); + } + + /** + * Method clearSearch + * @method clearSearch + * + * @description + * Clears only the local search and emits empty search intent without changing financial scope. + * + * @access protected + * @since unreleased + * + * @returns {void} No return value. + */ + protected clearSearch(): void { + if (this.pending()) return; + this.searchForm().reset({ search: '' }); + this.searchSubmitted.emit(''); + } + + /** + * Method equipmentLabel + * @method equipmentLabel + * + * @description + * Names an equipment choice using authorized patrimonial identity and never a raw UUID fallback. + * + * @access protected + * @since unreleased + * + * @param {MaintenanceFinancialEquipmentIdentity} equipment - Minimal financial equipment + * identity. + * + * @returns {string} Named equipment, asset reference or explicit missing label. + */ + protected equipmentLabel(equipment: MaintenanceFinancialEquipmentIdentity): string { + if (equipment.assetReference && equipment.name) + return `${equipment.assetReference} · ${equipment.name}`; + return ( + equipment.assetReference || + equipment.name || + $localize`:@@maintenanceCost.directory.unnamedEquipment:Unnamed equipment` + ); + } + + /** + * Method equipmentAssignmentKey + * @method equipmentAssignmentKey + * + * @description + * Distinguishes one asset's captured site and customer assignments in the financial directory. + * + * @access protected + * @since unreleased + * + * @param {MaintenanceFinancialEquipmentIdentity} equipment - Captured minimal assignment. + * + * @returns {string} Stable equipment, site and customer identity tuple. + */ + protected equipmentAssignmentKey(equipment: MaintenanceFinancialEquipmentIdentity): string { + return `${equipment.id}:${equipment.site?.id ?? ''}:${equipment.customer?.id ?? ''}`; + } + + /** + * Method selectEquipment + * @method selectEquipment + * + * @description + * Emits the selected equipment's own named site/customer scope; unavailable historical context + * remains absent rather than inheriting a different dossier target. + * + * @access protected + * @since unreleased + * + * @param {MaintenanceFinancialEquipmentIdentity} equipment - Equipment selected from that + * dossier. + * + * @returns {void} No return value. + */ + protected selectEquipment(equipment: MaintenanceFinancialEquipmentIdentity): void { + if (this.pending()) return; + this.scopeSelected.emit({ + equipment, + ...(equipment.site ? { site: equipment.site } : {}), + ...(equipment.customer ? { customer: equipment.customer } : {}), + }); + } + + /** + * Method snapshotLabel + * @method snapshotLabel + * + * @description + * Distinguishes recorded publication identity, current work and historical missing snapshots. + * + * @access protected + * @since unreleased + * + * @param {MaintenanceFinancialDossierOutput['snapshotState']} state - Server snapshot + * availability. + * + * @returns {string} Localized financial identity provenance. + */ + protected snapshotLabel(state: MaintenanceFinancialDossierOutput['snapshotState']): string { + switch (state) { + case 'available': + return $localize`:@@maintenanceCost.directory.published:Published snapshot`; + case 'snapshot_missing': + return $localize`:@@maintenanceCost.directory.missing:Historical snapshot missing`; + default: + return $localize`:@@maintenanceCost.directory.live:Current work`; + } + } + //#endregion +} diff --git a/src/app/features/organization/features/maintenance-costs/ui/dataviews/maintenance-financial-directory/testing/maintenance-financial-directory.component.spec.ts b/src/app/features/organization/features/maintenance-costs/ui/dataviews/maintenance-financial-directory/testing/maintenance-financial-directory.component.spec.ts new file mode 100644 index 000000000..d79c8d7b8 --- /dev/null +++ b/src/app/features/organization/features/maintenance-costs/ui/dataviews/maintenance-financial-directory/testing/maintenance-financial-directory.component.spec.ts @@ -0,0 +1,420 @@ +import { provideZonelessChangeDetection, signal } from '@angular/core'; +import { TestBed, type ComponentFixture } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { provideRouter } from '@angular/router'; +import { toStoreError } from '@core/request-state'; +import { THEME_PORT, type ThemePort } from '@core/theme'; +import type { + MaintenanceFinancialDossierOutput, + MaintenanceReportScopeSelection, +} from '@features/organization/features/maintenance-costs/models'; +import { REGIONAL_FORMATTING_PORT } from '@features/organization/ports'; +import { MaintenanceFinancialDirectory } from '../maintenance-financial-directory.component'; + +class ResizeObserverStub { + public observe(): void {} + public unobserve(): void {} + public disconnect(): void {} +} + +function dossier( + overrides: Partial = {}, +): MaintenanceFinancialDossierOutput { + return { + '@id': '/api/organizations/organization-1/maintenance-cost/dossiers/work-1', + '@type': 'MaintenanceFinancialDossier', + id: '46a171e4-c87d-4d1a-bde4-9fc4042ab222', + number: 81, + name: 'Replace reception extinguisher', + type: 'corrective', + status: 'published', + publishedAt: '2026-01-15T13:30:00Z', + snapshotState: 'available', + identityComplete: true, + site: { id: '26bf4877-7a19-4c03-94a9-292cb96a3a58', name: 'Recorded Lyon site' }, + customer: { id: '932c30e3-dcd1-4575-8a11-ebcb9c7d1af8', name: 'Recorded customer' }, + equipment: [ + { + id: 'd43c7404-311c-4a6f-8d1f-0e2ba8a0d459', + name: 'Reception extinguisher', + assetReference: 'EXT-081', + site: { id: '26bf4877-7a19-4c03-94a9-292cb96a3a58', name: 'Recorded Lyon site' }, + customer: { id: '932c30e3-dcd1-4575-8a11-ebcb9c7d1af8', name: 'Recorded customer' }, + }, + ], + ...overrides, + }; +} + +function text(element: Element | null): string { + return element?.textContent?.replace(/\s+/g, ' ').trim() ?? ''; +} + +describe('MaintenanceFinancialDirectory', () => { + let fixture: ComponentFixture; + let searches: string[]; + let scopeSelections: MaintenanceReportScopeSelection[]; + let pages: number[]; + let pageSizes: number[]; + let retryCount: number; + const root = (): HTMLElement => fixture.nativeElement as HTMLElement; + const searchInput = (): HTMLInputElement => { + const input = root().querySelector('#maintenance-financial-search'); + if (!input) throw new Error('Missing financial search input'); + return input; + }; + const action = (label: string): HTMLButtonElement => { + const button = Array.from(root().querySelectorAll('button')).find( + (candidate) => text(candidate).startsWith(label), + ); + if (!button) throw new Error('Missing financial directory action ' + label); + return button; + }; + const setSearch = async (value: string): Promise => { + const input = searchInput(); + input.value = value; + input.dispatchEvent(new Event('input', { bubbles: true })); + await fixture.whenStable(); + }; + const submit = async (): Promise => { + root() + .querySelector('form') + ?.dispatchEvent(new Event('submit', { cancelable: true })); + await fixture.whenStable(); + }; + + beforeAll(() => { + globalThis.ResizeObserver ??= ResizeObserverStub as unknown as typeof ResizeObserver; + }); + + beforeEach(async () => { + const theme: ThemePort = { + theme: signal('light'), + resolvedTheme: signal('light'), + setTheme: vi.fn(), + }; + TestBed.configureTestingModule({ + providers: [ + provideZonelessChangeDetection(), + provideRouter([]), + { provide: THEME_PORT, useValue: theme }, + { + provide: REGIONAL_FORMATTING_PORT, + useValue: { regionalFormatting: signal({ dateFormat: 'dd/MM/yyyy', timezone: 'UTC' }) }, + }, + ], + }); + fixture = TestBed.createComponent(MaintenanceFinancialDirectory); + fixture.componentRef.setInput('organizationId', 'organization-1'); + fixture.componentRef.setInput('scope', 'organization-1/session-1'); + fixture.componentRef.setInput('items', [dossier()]); + fixture.componentRef.setInput('totalItems', 1); + fixture.componentRef.setInput('search', 'initial reference'); + searches = []; + scopeSelections = []; + pages = []; + pageSizes = []; + retryCount = 0; + fixture.componentInstance.searchSubmitted.subscribe((value) => searches.push(value)); + fixture.componentInstance.scopeSelected.subscribe((value) => scopeSelections.push(value)); + fixture.componentInstance.pageChanged.subscribe((value) => pages.push(value)); + fixture.componentInstance.pageSizeChanged.subscribe((value) => pageSizes.push(value)); + fixture.componentInstance.retryRequested.subscribe(() => retryCount++); + await fixture.whenStable(); + }); + + afterEach(() => TestBed.resetTestingModule()); + + it('submits trimmed name or reference text without changing local pagination or scope', async () => { + await setSearch(' INT-2026-0081 '); + await submit(); + + expect(searches).toEqual(['INT-2026-0081']); + expect(scopeSelections).toEqual([]); + expect(pages).toEqual([]); + expect(searchInput().value).toBe(' INT-2026-0081 '); + }); + + it('accepts exactly 160 search characters and rejects a longer search with field feedback', async () => { + await setSearch('a'.repeat(160)); + await submit(); + expect(searches).toEqual(['a'.repeat(160)]); + + await setSearch('a'.repeat(161)); + await submit(); + expect(searches).toEqual(['a'.repeat(160)]); + expect(text(root().querySelector('hlm-field-error'))).toBe( + 'Use at most 160 characters for the work reference or name.', + ); + }); + + it('preserves edited search across pending, failed and ordinary successful server reads', async () => { + await setSearch('private unsent query'); + fixture.componentRef.setInput('pending', true); + fixture.componentRef.setInput('search', 'new server reference'); + await fixture.whenStable(); + expect(searchInput().value).toBe('private unsent query'); + + fixture.componentRef.setInput('pending', false); + fixture.componentRef.setInput('error', toStoreError(new Error('Directory unavailable'))); + await fixture.whenStable(); + expect(searchInput().value).toBe('private unsent query'); + + fixture.componentRef.setInput('error', null); + fixture.componentRef.setInput('items', [dossier({ name: 'Refreshed work label' })]); + await fixture.whenStable(); + expect(searchInput().value).toBe('private unsent query'); + expect(root().textContent).toContain('Refreshed work label'); + }); + + it('adopts the explicit reset query and clears private edits on a supplied session replacement', async () => { + await setSearch('private draft'); + fixture.componentRef.setInput('search', 'reset reference'); + fixture.componentRef.setInput('resetToken', 1); + await fixture.whenStable(); + expect(searchInput().value).toBe('reset reference'); + + await setSearch('new private draft'); + fixture.componentRef.setInput('search', ''); + fixture.componentRef.setInput('scope', 'organization-1/session-2'); + await fixture.whenStable(); + expect(searchInput().value).toBe(''); + expect(searches).toEqual([]); + }); + + it('clears only the search text and emits empty search intent', async () => { + await setSearch('repair reference'); + action('Clear search').click(); + await fixture.whenStable(); + + expect(searchInput().value).toBe(''); + expect(searches).toEqual(['']); + expect(scopeSelections).toEqual([]); + expect(root().textContent).toContain('Recorded Lyon site'); + }); + + it('emits site or customer replacement scopes without carrying incompatible prior filters', () => { + const entry = dossier(); + action('Limit to site').click(); + action('Limit to customer').click(); + + expect(scopeSelections).toEqual([{ site: entry.site }, { customer: entry.customer }]); + expect(searches).toEqual([]); + }); + + it('emits equipment together with only its own compatible named site and customer context', () => { + const entry = dossier(); + action('Limit to equipment').click(); + + expect(scopeSelections).toEqual([ + { equipment: entry.equipment[0], site: entry.site, customer: entry.customer }, + ]); + expect(text(action('Limit to equipment'))).toBe( + 'Limit to equipment: EXT-081 · Reception extinguisher', + ); + }); + + it('keeps two captured assignments of the same equipment distinct and emits the clicked second scope', async () => { + const first = dossier().equipment[0]; + const second = { + ...first, + name: 'Reception extinguisher after transfer', + site: { id: 'c02b929b-4c49-47c1-b8dc-8f1dd9769d56', name: 'Recorded Paris site' }, + customer: { id: 'bddf901a-d6e1-4640-8178-1648d3b08b51', name: 'Second recorded customer' }, + }; + const warnings = vi.spyOn(console, 'warn'); + try { + fixture.componentRef.setInput('items', [dossier({ equipment: [first, second] })]); + await fixture.whenStable(); + const buttons = [ + ...root().querySelectorAll( + '[aria-label="Equipment report filters"] button', + ), + ]; + expect(buttons).toHaveLength(2); + expect( + warnings.mock.calls.some((call) => call.some((value) => String(value).includes('NG0955'))), + ).toBe(false); + buttons[1]?.click(); + expect(scopeSelections).toEqual([ + { equipment: second, site: second.site, customer: second.customer }, + ]); + fixture.componentRef.setInput('items', [dossier({ equipment: [second, first] })]); + await fixture.whenStable(); + const reordered = [ + ...root().querySelectorAll( + '[aria-label="Equipment report filters"] button', + ), + ]; + expect(reordered[0]).toBe(buttons[1]); + reordered[0]?.click(); + expect(scopeSelections[1]).toEqual({ + equipment: second, + site: second.site, + customer: second.customer, + }); + } finally { + warnings.mockRestore(); + } + }); + + it('omits unavailable site and customer context for an equipment-only historical dossier', async () => { + const entry = dossier({ + site: null, + customer: null, + identityComplete: false, + equipment: [{ ...dossier().equipment[0], site: null, customer: null }], + }); + fixture.componentRef.setInput('items', [entry]); + await fixture.whenStable(); + action('Limit to equipment').click(); + + expect(scopeSelections).toEqual([{ equipment: entry.equipment[0] }]); + expect(root().textContent).not.toContain('Limit to site'); + expect(root().textContent).not.toContain('Limit to customer'); + }); + + it('uses the selected equipment context instead of the root dossier in a multi-site campaign', async () => { + const equipment = { + ...dossier().equipment[0], + site: { id: 'equipment-site-b', name: 'Equipment site B' }, + customer: { id: 'equipment-customer-b', name: 'Equipment customer B' }, + }; + fixture.componentRef.setInput('items', [dossier({ equipment: [equipment] })]); + await fixture.whenStable(); + action('Limit to equipment').click(); + expect(scopeSelections).toEqual([ + { equipment, site: equipment.site, customer: equipment.customer }, + ]); + }); + + it.each([null, undefined])( + 'never replaces unavailable per-equipment context %s with a different dossier scope', + async (context) => { + const equipment = { ...dossier().equipment[0], site: context, customer: context }; + fixture.componentRef.setInput('items', [dossier({ equipment: [equipment] })]); + await fixture.whenStable(); + action('Limit to equipment').click(); + expect(scopeSelections).toEqual([{ equipment }]); + }, + ); + + it('opens the private financial route with the owning organization and exact work query', () => { + const entry = dossier(); + const link = root().querySelector('article a'); + + expect(text(link)).toBe('Open financial dossier'); + expect(link?.getAttribute('href')).toBe( + '/organizations/organization-1/maintenance-costs?interventionId=' + entry.id, + ); + expect(root().querySelector('article a[href*="/interventions/"]')).toBeNull(); + }); + + it('shows missing historical identity and unnamed equipment without a raw UUID fallback', async () => { + const entry = dossier({ + snapshotState: 'snapshot_missing', + identityComplete: false, + publishedAt: null, + site: null, + customer: null, + equipment: [{ id: 'e26a36ba-e705-499d-a5fe-5c3eeab5ccdf', name: null, assetReference: null }], + }); + fixture.componentRef.setInput('items', [entry]); + await fixture.whenStable(); + + expect(root().textContent).toContain('Historical snapshot missing'); + expect(root().textContent).toContain( + 'Historical identity is incomplete. Only available named filters can be selected.', + ); + expect(text(action('Limit to equipment'))).toBe('Limit to equipment: Unnamed equipment'); + expect(root().textContent).not.toContain(entry.id); + expect(root().textContent).not.toContain(entry.equipment[0].id); + expect(root().textContent).not.toContain('Published on'); + }); + + it.each([ + ['available', 'Published snapshot'], + ['snapshot_missing', 'Historical snapshot missing'], + ['live', 'Current work'], + ] as const)('distinguishes the %s identity provenance', async (snapshotState, expected) => { + fixture.componentRef.setInput('items', [dossier({ snapshotState })]); + await fixture.whenStable(); + expect(text(root().querySelector('[hlmBadge]'))).toBe(expected); + }); + + it('retains rows and search after failure and emits a retry without a new search command', async () => { + await setSearch('repair draft'); + fixture.componentRef.setInput('error', toStoreError(new Error('Directory unavailable'))); + await fixture.whenStable(); + action('Retry directory').click(); + + expect(text(root().querySelector('[role="alert"]'))).toContain('Directory unavailable'); + expect(root().querySelectorAll('article')).toHaveLength(1); + expect(root().textContent).toContain('Replace reception extinguisher'); + expect(searchInput().value).toBe('repair draft'); + expect(retryCount).toBe(1); + expect(searches).toEqual([]); + }); + + it('retains named rows but blocks search, clear and scope actions while a read is pending', async () => { + await setSearch('repair draft'); + fixture.componentRef.setInput('pending', true); + await fixture.whenStable(); + await submit(); + action('Clear search').click(); + action('Limit to site').click(); + action('Limit to customer').click(); + action('Limit to equipment').click(); + + expect(searches).toEqual([]); + expect(scopeSelections).toEqual([]); + expect(searchInput().value).toBe('repair draft'); + expect(root().querySelectorAll('article')).toHaveLength(1); + expect(text(root().querySelector('[role="status"]'))).toBe('Reading financial dossiers'); + expect(root().querySelector('section')?.getAttribute('aria-busy')).toBe('true'); + }); + + it('pages using more than 500 server results and forwards bounded page-size intent', async () => { + fixture.componentRef.setInput('page', 2); + fixture.componentRef.setInput('itemsPerPage', 25); + fixture.componentRef.setInput('totalItems', 1131); + await fixture.whenStable(); + + expect(text(root().querySelector('[data-testid="maintenance-financial-row-count"]'))).toBe( + '1 of 1131 rows shown', + ); + expect(text(root().querySelector('[data-testid="maintenance-financial-page-indicator"]'))).toBe( + 'Page 2 of 46', + ); + root() + .querySelector('[data-testid="maintenance-financial-page-last"]') + ?.click(); + fixture.debugElement.query(By.css('hlm-select')).triggerEventHandler('valueChange', 120); + + expect(pages).toEqual([46]); + expect(pageSizes).toEqual([120]); + expect(fixture.componentInstance.page()).toBe(2); + expect(fixture.componentInstance.items()).toHaveLength(1); + }); + + it('distinguishes a completed empty search from a pending read and an initial failure', async () => { + fixture.componentRef.setInput('items', []); + fixture.componentRef.setInput('totalItems', 0); + await fixture.whenStable(); + expect(root().textContent).toContain('No financial dossiers in this scope'); + + fixture.componentRef.setInput('pending', true); + await fixture.whenStable(); + expect(root().textContent).not.toContain('No financial dossiers in this scope'); + + fixture.componentRef.setInput('pending', false); + fixture.componentRef.setInput('error', toStoreError(new Error('Directory unavailable'))); + await fixture.whenStable(); + expect(root().textContent).not.toContain('No financial dossiers in this scope'); + expect(text(root().querySelector('[role="alert"]'))).toContain( + 'Financial dossiers could not be loaded', + ); + action('Retry directory').click(); + expect(retryCount).toBe(1); + }); +}); diff --git a/src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-currency-form/index.ts b/src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-currency-form/index.ts new file mode 100644 index 000000000..2f790cafe --- /dev/null +++ b/src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-currency-form/index.ts @@ -0,0 +1 @@ +export { MaintenanceCurrencyForm } from './maintenance-currency-form.component'; diff --git a/src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-currency-form/maintenance-currency-form.component.html b/src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-currency-form/maintenance-currency-form.component.html new file mode 100644 index 000000000..1d4be4357 --- /dev/null +++ b/src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-currency-form/maintenance-currency-form.component.html @@ -0,0 +1,58 @@ +@if (canManage() && !currency()?.locked) { +
+
+ + +

+ Use a three-letter uppercase code, for example EUR. Currency is locked after the first + financial record. +

+ @if (fields.currency().touched()) { + @for (validation of fields.currency().errors(); track validation) { + {{ validation.message }} + } + } +
+ @if (error(); as failure) { +
+
+ } +
+ +
+
+} diff --git a/src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-currency-form/maintenance-currency-form.component.ts b/src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-currency-form/maintenance-currency-form.component.ts new file mode 100644 index 000000000..77b4eae72 --- /dev/null +++ b/src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-currency-form/maintenance-currency-form.component.ts @@ -0,0 +1,236 @@ +import { + ChangeDetectionStrategy, + Component, + effect, + input, + output, + signal, + untracked, + type InputSignal, + type OutputEmitterRef, + type WritableSignal, +} from '@angular/core'; +import { disabled, form, FormField, validate, type FieldTree } from '@angular/forms/signals'; +import { NgIcon, provideIcons } from '@ng-icons/core'; +import { lucideCircleAlert } from '@ng-icons/lucide'; +import type { StoreError } from '@core/request-state'; +import type { MaintenanceCurrencyOutput } from '@features/organization/features/maintenance-costs/models'; +import { HlmAlertImports } from '@shared/ui/alert'; +import { HlmButton } from '@shared/ui/button'; +import { HlmFieldImports } from '@shared/ui/field'; +import { HlmInput } from '@shared/ui/input'; +import { HlmSpinner } from '@shared/ui/spinner'; + +/** + * Class MaintenanceCurrencyForm + * @class MaintenanceCurrencyForm + * + * @description + * Edits the exact organization currency before the server locks financial facts to it. + */ +@Component({ + selector: 'app-maintenance-currency-form', + templateUrl: './maintenance-currency-form.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + FormField, + NgIcon, + HlmButton, + HlmInput, + HlmSpinner, + ...HlmFieldImports, + ...HlmAlertImports, + ], + providers: [provideIcons({ lucideCircleAlert })], +}) +export class MaintenanceCurrencyForm { + //#region Properties + /** + * Property currency + * @readonly + * + * @description + * Last confirmed currency and server lock, never inferred from local costs. + * + * @access public + * @since 2026-10-06 + * + * @type {InputSignal} + */ + public readonly currency: InputSignal = + input(null); + + /** + * Property canManage + * @readonly + * + * @description + * Effective permission supplied by the owner; false renders no edit form. + * + * @access public + * @since 2026-10-06 + * + * @type {InputSignal} + */ + public readonly canManage: InputSignal = input(false); + + /** + * Property pending + * @readonly + * + * @description + * Locks the current draft while its write is accepted. + * + * @access public + * @since 2026-10-06 + * + * @type {InputSignal} + */ + public readonly pending: InputSignal = input(false); + + /** + * Property error + * @readonly + * + * @description + * Recoverable write failure, which never discards the draft. + * + * @access public + * @since 2026-10-06 + * + * @type {InputSignal} + */ + public readonly error: InputSignal = input(null); + + /** + * Property resetToken + * @readonly + * + * @description + * The owner increments this only after an acknowledged successful save or context reset. + * + * @access public + * @since 2026-10-06 + * + * @type {InputSignal} + */ + public readonly resetToken: InputSignal = input(0); + + /** + * Property submitted + * @readonly + * + * @description + * Emits an uppercase three-letter code without allocating operation identities. + * + * @access public + * @since 2026-10-06 + * + * @type {OutputEmitterRef} + */ + public readonly submitted: OutputEmitterRef = output(); + + /** + * Property draft + * @readonly + * + * @description + * Local text remains intact after a rejected save or unrelated refresh. + * + * @access protected + * @since 2026-10-06 + * + * @type {WritableSignal<{ currency: string }>} + */ + protected readonly draft: WritableSignal<{ currency: string }> = signal({ currency: '' }); + + /** + * Property currencyErrorFallback + * @readonly + * + * @description + * Readable localized explanation when a rejected request has no message. + * + * @access protected + * @since 2026-10-06 + * + * @type {string} + */ + protected readonly currencyErrorFallback: string = $localize`:@@maintenanceCost.settings.currencySaveError:Your currency draft is preserved. Try saving again.`; + + /** + * Property fields + * @readonly + * + * @description + * Native field state enforces an exact uppercase code and the server lock. + * + * @access protected + * @since 2026-10-06 + * + * @type {FieldTree<{ currency: string }>} + */ + protected readonly fields: FieldTree<{ currency: string }> = form(this.draft, (path) => { + disabled(path, () => this.pending() || !this.canManage() || !!this.currency()?.locked); + validate(path.currency, ({ value }) => + /^[A-Z]{3}$/.test(value()) + ? null + : { + kind: 'currency', + message: $localize`:@@maintenanceCost.settings.currencyRequired:Enter a three-letter uppercase currency code.`, + }, + ); + }); + //#endregion + + //#region Constructor + /** + * Constructor + * @constructor + * + * @description + * Refreshes pristine fields and honors explicit successful resets without losing rejected edits. + * + * @access public + * @since 2026-10-06 + */ + public constructor() { + effect(() => { + const currency: MaintenanceCurrencyOutput | null = this.currency(); + untracked(() => { + if (!this.fields().dirty() || currency?.locked) { + this.fields().reset({ currency: currency?.currency ?? '' }); + } + }); + }); + effect(() => { + this.resetToken(); + untracked(() => this.fields().reset({ currency: this.currency()?.currency ?? '' })); + }); + } + //#endregion + + //#region Methods + /** + * Method submit + * @method submit + * + * @description + * Emits only valid, authorized changes before any financial fact locks the currency. + * + * @access protected + * @since 2026-10-06 + * + * @param {Event} event - Native submission event. + * + * @returns {void} + */ + protected submit(event: Event): void { + event.preventDefault(); + if (this.pending() || !this.canManage() || this.currency()?.locked) return; + this.fields().markAsTouched(); + if (this.fields().invalid()) return; + this.submitted.emit(this.draft().currency); + } + //#endregion +} diff --git a/src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-currency-form/testing/maintenance-currency-form.component.spec.ts b/src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-currency-form/testing/maintenance-currency-form.component.spec.ts new file mode 100644 index 000000000..b8f9d5102 --- /dev/null +++ b/src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-currency-form/testing/maintenance-currency-form.component.spec.ts @@ -0,0 +1,107 @@ +import { TestBed, type ComponentFixture } from '@angular/core/testing'; +import { toStoreError } from '@core/request-state'; +import type { MaintenanceCurrencyOutput } from '@features/organization/features/maintenance-costs/models'; +import { MaintenanceCurrencyForm } from '../maintenance-currency-form.component'; + +describe('MaintenanceCurrencyForm', () => { + let fixture: ComponentFixture; + let submissions: string[]; + const currency: MaintenanceCurrencyOutput = { + '@id': '/maintenance-cost/currency', + '@type': 'MaintenanceCurrency', + organizationId: 'organization', + currency: 'EUR', + locked: false, + }; + const root = (): HTMLElement => fixture.nativeElement as HTMLElement; + const setValue = async (value: string): Promise => { + const control: HTMLInputElement | null = root().querySelector('#maintenance-cost-currency'); + if (!control) throw new Error('Missing currency control'); + control.value = value; + control.dispatchEvent(new Event('input', { bubbles: true })); + await fixture.whenStable(); + }; + const submit = async (): Promise => { + root() + .querySelector('form') + ?.dispatchEvent(new Event('submit', { cancelable: true })); + await fixture.whenStable(); + }; + + beforeEach(async () => { + fixture = TestBed.createComponent(MaintenanceCurrencyForm); + submissions = []; + fixture.componentInstance.submitted.subscribe((value: string) => submissions.push(value)); + fixture.componentRef.setInput('currency', currency); + fixture.componentRef.setInput('canManage', true); + await fixture.whenStable(); + }); + + it('emits the exact uppercase three-letter code', async () => { + await setValue('USD'); + await submit(); + expect(submissions).toEqual(['USD']); + }); + + it.each(['eur', 'EU', 'EURO', ' EUR '])( + 'rejects a malformed currency %s', + async (value: string) => { + await setValue(value); + await submit(); + expect(submissions).toEqual([]); + expect(root().querySelector('hlm-field-error')?.textContent).toContain( + 'three-letter uppercase', + ); + }, + ); + + it('retains the draft after a rejection and unrelated server refresh', async () => { + await setValue('USD'); + fixture.componentRef.setInput('error', toStoreError(new Error('Save failed'))); + fixture.componentRef.setInput('currency', { ...currency }); + await fixture.whenStable(); + expect(root().querySelector('#maintenance-cost-currency')?.value).toBe('USD'); + expect(root().querySelector('[role="alert"]')?.textContent).toContain('Save failed'); + await submit(); + expect(submissions).toEqual(['USD']); + }); + + it('has no edit form when currency is locked, even for a manager', async () => { + fixture.componentRef.setInput('currency', { ...currency, locked: true }); + await fixture.whenStable(); + expect(root().querySelector('form')).toBeNull(); + expect(submissions).toEqual([]); + }); + + it('has no edit form without management permission', async () => { + fixture.componentRef.setInput('canManage', false); + await fixture.whenStable(); + expect(root().querySelector('form')).toBeNull(); + expect(submissions).toEqual([]); + }); + + it('locks native fields and prevents resubmission while a write is pending', async () => { + await setValue('USD'); + fixture.componentRef.setInput('pending', true); + await fixture.whenStable(); + expect(root().querySelector('#maintenance-cost-currency')?.disabled).toBe( + true, + ); + await submit(); + expect(submissions).toEqual([]); + fixture.componentRef.setInput('pending', false); + await fixture.whenStable(); + expect(root().querySelector('#maintenance-cost-currency')?.value).toBe('USD'); + }); + + it('resets only after the owner acknowledges success', async () => { + await setValue('USD'); + fixture.componentRef.setInput('currency', { ...currency, currency: 'GBP' }); + await fixture.whenStable(); + expect(root().querySelector('#maintenance-cost-currency')?.value).toBe('USD'); + fixture.componentRef.setInput('resetToken', 1); + await fixture.whenStable(); + expect(root().querySelector('#maintenance-cost-currency')?.value).toBe('GBP'); + expect(root().querySelectorAll('hlm-field-error')).toHaveLength(0); + }); +}); diff --git a/src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-expense-form/index.ts b/src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-expense-form/index.ts new file mode 100644 index 000000000..6f1b26512 --- /dev/null +++ b/src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-expense-form/index.ts @@ -0,0 +1 @@ +export { MaintenanceExpenseForm } from './maintenance-expense-form.component'; diff --git a/src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-expense-form/maintenance-expense-form.component.html b/src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-expense-form/maintenance-expense-form.component.html new file mode 100644 index 000000000..d5b2eae91 --- /dev/null +++ b/src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-expense-form/maintenance-expense-form.component.html @@ -0,0 +1,145 @@ +
+
+ + @if (originalExpense()) { + Adjust the original expense + } @else { + Add an external expense + } + +

+ Expenses and corrections are appended to the history. The closure snapshot remains unchanged. +

+ @if (originalExpense(); as original) { +

{{ original.description }}

+ } +
+
+ + + @if (expenseForm.amount().touched()) { + @for (error of expenseForm.amount().errors(); track error) { + {{ error.message }} + } + } +
+
+ + +

{{ timezone() }}

+ @if (expenseForm.incurredAt().touched()) { + @for (error of expenseForm.incurredAt().errors(); track error) { + {{ error.message }} + } + } +
+ @if (possibleTimes().length > 1) { +
+ + + + + + @for (date of possibleTimes(); track date.toMillis()) { + UTC {{ date.toFormat('ZZ') }} + } + + + +

+ The same local time occurred twice because the clocks changed. Select the UTC offset + when the expense actually occurred. +

+ @if (expenseForm.offsetChoice().touched()) { + @for (error of expenseForm.offsetChoice().errors(); track error) { + {{ error.message }} + } + } +
+ } +
+
+ + + @if (expenseForm.description().touched()) { + @for (error of expenseForm.description().errors(); track error) { + {{ error.message }} + } + } +
+
+ @if (error()) { +

{{ error()?.message }}

+ } +
+ + @if (originalExpense()) { + + } +
+
diff --git a/src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-expense-form/maintenance-expense-form.component.ts b/src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-expense-form/maintenance-expense-form.component.ts new file mode 100644 index 000000000..08e144e7c --- /dev/null +++ b/src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-expense-form/maintenance-expense-form.component.ts @@ -0,0 +1,449 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + input, + output, + signal, + untracked, + type InputSignal, + type OutputEmitterRef, + type Signal, + type WritableSignal, +} from '@angular/core'; +import { + disabled, + form, + FormField, + FormRoot, + maxLength, + required, + validate, + type FieldTree, +} from '@angular/forms/signals'; +import { DateTime } from 'luxon'; +import type { StoreError } from '@core/request-state'; +import type { + CreateMaintenanceExpenseInput, + MaintenanceCostItem, +} from '@features/organization/features/maintenance-costs/models'; +import { isMaintenanceAmount } from '@features/organization/features/maintenance-costs/utils'; +import { HlmButton } from '@shared/ui/button'; +import { HlmFieldImports } from '@shared/ui/field'; +import { HlmInput } from '@shared/ui/input'; +import { HlmSelectImports } from '@shared/ui/select'; +import { HlmSpinner } from '@shared/ui/spinner'; +import { HlmTextarea } from '@shared/ui/textarea'; + +/** + * Interface ExpenseDraft + * @interface ExpenseDraft + * + * @description + * Text fields retain exact amounts; the actual date is entered in the organization timezone. + */ +interface ExpenseDraft { + /** + * Property amount + * + * @description + * Exact contract value for amount. + * + * @type {string} + */ + amount: string; + /** + * Property description + * + * @description + * Exact contract value for description. + * + * @type {string} + */ + description: string; + /** + * Property incurredAt + * + * @description + * Exact contract value for incurredAt. + * + * @type {string} + */ + incurredAt: string; + + /** + * Property offsetChoice + * + * @description + * Explicit UTC offset distinguishing the two possible instants of a repeated local hour. + * + * @type {string} + */ + offsetChoice: string; +} + +/** + * Class MaintenanceExpenseForm + * @class MaintenanceExpenseForm + * + * @description + * Emits a motivated append-only expense or signed correction, leaving replay identity to the owning + * store/page. + */ +@Component({ + selector: 'app-maintenance-expense-form', + templateUrl: './maintenance-expense-form.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + FormField, + FormRoot, + HlmButton, + HlmInput, + HlmSpinner, + HlmTextarea, + ...HlmFieldImports, + ...HlmSelectImports, + ], +}) +export class MaintenanceExpenseForm { + //#region Properties + /** + * Property scope + * @readonly + * + * @description + * Private organization and intervention context for this draft. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly scope: InputSignal = input.required(); + /** + * Property timezone + * @readonly + * + * @description + * Organization timezone used to interpret the actual local expense timestamp. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly timezone: InputSignal = input('UTC'); + /** + * Property pending + * @readonly + * + * @description + * An accepted write is awaiting acknowledgement. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly pending: InputSignal = input(false); + /** + * Property locked + * @readonly + * + * @description + * The owning permission, connectivity or replay state prevents editing. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly locked: InputSignal = input(false); + /** + * Property error + * @readonly + * + * @description + * Latest financial error, retained alongside the unchanged local draft. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly error: InputSignal = input(null); + /** + * Property originalExpense + * @readonly + * + * @description + * Original immutable expense linked by a signed correcting declaration. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly originalExpense: InputSignal = + input(null); + /** + * Property resetToken + * @readonly + * + * @description + * Changes only after confirmed save or replacement of the private session context. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly resetToken: InputSignal = input(0); + /** + * Property submitted + * @readonly + * + * @description + * Validated exact declaration for the owning page to save. + * + * @access public + * @since unreleased + * + * @type {OutputEmitterRef>} + */ + public readonly submitted: OutputEmitterRef> = + output>(); + /** + * Property cancelled + * @readonly + * + * @description + * Intent to dismiss the original expense adjustment. + * + * @access public + * @since unreleased + * + * @type {OutputEmitterRef} + */ + public readonly cancelled: OutputEmitterRef = output(); + /** + * Property draft + * @readonly + * + * @description + * Editable exact strings; blank forecasts remain explicitly unknown. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal} + */ + protected readonly draft: WritableSignal = signal({ + amount: '', + description: '', + incurredAt: '', + offsetChoice: '', + }); + + /** + * Property possibleTimes + * @readonly + * + * @description + * Keeps every possible instant in the organization timezone while rejecting normalized local + * hours that never occurred. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly possibleTimes: Signal = computed(() => { + const local: string = this.draft().incurredAt.replace(/\.0{1,3}$/, ''); + const date: DateTime = DateTime.fromISO(local, { zone: this.timezone() }); + return date.isValid && + /^(\d{4}-\d{2}-\d{2})T\d{2}:\d{2}(?::\d{2})?$/.test(local) && + date.toFormat(local.length === 16 ? "yyyy-MM-dd'T'HH:mm" : "yyyy-MM-dd'T'HH:mm:ss") === local + ? date.getPossibleOffsets() + : []; + }); + + /** + * Property resolvedTime + * @readonly + * + * @description + * Repeated local hours stay unresolved until their actual UTC offset is explicitly selected. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly resolvedTime: Signal = computed(() => { + const times: readonly DateTime[] = this.possibleTimes(); + return times.length === 1 + ? (times[0] ?? null) + : (times.find((date) => date.toFormat('ZZ') === this.draft().offsetChoice) ?? null); + }); + /** + * Property expenseForm + * @readonly + * + * @description + * Native Signal Form for a motivated expense or append-only correction. + * + * @access protected + * @since unreleased + * + * @type {FieldTree} + */ + protected readonly expenseForm: FieldTree = form(this.draft, (path) => { + disabled(path, () => this.pending() || this.locked()); + required(path.amount, { + message: $localize`:@@maintenanceCost.expense.amountRequired:Enter the exact expense amount.`, + }); + validate(path.amount, ({ value }) => + isMaintenanceAmount(value(), !!this.originalExpense()) + ? null + : { + kind: 'amount', + message: $localize`:@@maintenanceCost.expense.exactAmount:Enter an exact decimal with at most six decimal places. A negative correction requires its original expense.`, + }, + ); + required(path.description, { + message: $localize`:@@maintenanceCost.expense.reasonRequired:Describe the expense or explain its correction.`, + }); + maxLength(path.description, 2000); + validate(path.description, ({ value }) => + value().trim() + ? null + : { + kind: 'required', + message: $localize`:@@maintenanceCost.expense.reasonRequired:Describe the expense or explain its correction.`, + }, + ); + required(path.incurredAt, { + message: $localize`:@@maintenanceCost.expense.dateRequired:Enter the actual expense date and time.`, + }); + validate(path.incurredAt, () => { + const date: DateTime | null = + this.resolvedTime() ?? + this.possibleTimes().find((candidate) => candidate <= DateTime.now()) ?? + null; + return date !== null && date <= DateTime.now() + ? null + : { + kind: 'date', + message: $localize`:@@maintenanceCost.expense.actualDate:Enter a valid actual date that is not in the future.`, + }; + }); + validate(path.offsetChoice, () => + this.possibleTimes().length <= 1 || this.resolvedTime() !== null + ? null + : { + kind: 'offset', + message: $localize`:@@maintenanceCost.expense.offsetRequired:This local hour occurs twice. Choose the UTC offset of the actual expense.`, + }, + ); + }); + /** + * Property seededScope + * + * @description + * Last draft initialization identity, preserving edits across ordinary reads and errors. + * + * @access private + * @since unreleased + * + * @type {string} + */ + private seededScope: string = ''; + + /** + * Property offsetTime + * + * @description + * Local timestamp and timezone already considered; changing either clears the offset decision. + * + * @access private + * @since unreleased + * + * @type {string} + */ + private offsetTime: string = ''; + //#endregion + + //#region Constructor + /** + * Constructor + * @constructor + * + * @description + * Connects native draft initialization and private financial session transitions. + * + * @access public + * @since unreleased + */ + public constructor() { + effect(() => { + const scope = this.scope(), + token = this.resetToken(), + original = this.originalExpense(), + timezone = this.timezone(); + const identity = `${scope}/${token}/${original?.sourceId ?? 'new'}`; + if (identity === this.seededScope) return; + this.seededScope = identity; + untracked(() => + this.expenseForm().reset({ + amount: '', + description: '', + incurredAt: DateTime.now().setZone(timezone).toFormat("yyyy-MM-dd'T'HH:mm:ss"), + offsetChoice: '', + }), + ); + }); + effect(() => { + const identity: string = `${this.timezone()}/${this.draft().incurredAt}`; + if (identity === this.offsetTime) return; + this.offsetTime = identity; + untracked(() => this.draft.update((draft) => ({ ...draft, offsetChoice: '' }))); + }); + } + //#endregion + + //#region Methods + /** + * Method submit + * @method submit + * + * @description + * Validates the native form and emits exact strings with only the applicable resource tuple. + * + * @access protected + * @since unreleased + * + * @param {Event} event - Native form submission event. + * + * @returns {void} No return value. + */ + protected submit(event: Event): void { + event.preventDefault(); + this.expenseForm().markAsTouched(); + if (this.expenseForm().invalid() || this.expenseForm().disabled()) return; + const value = this.draft(), + original = this.originalExpense(); + const incurredAt: string | null = + this.resolvedTime()?.toUTC().toISO({ suppressMilliseconds: true }) ?? null; + if (!incurredAt) return; + this.submitted.emit({ + amount: value.amount.trim(), + description: value.description.trim(), + incurredAt, + adjustmentOf: original?.sourceId ?? null, + workItemId: original?.workItemId ?? null, + }); + } + //#endregion +} diff --git a/src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-expense-form/testing/maintenance-expense-form.component.spec.ts b/src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-expense-form/testing/maintenance-expense-form.component.spec.ts new file mode 100644 index 000000000..5b810d5bf --- /dev/null +++ b/src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-expense-form/testing/maintenance-expense-form.component.spec.ts @@ -0,0 +1,190 @@ +import { TestBed, type ComponentFixture } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { BrnSelect } from '@spartan-ng/brain/select'; +import { toStoreError } from '@core/request-state'; +import type { + CreateMaintenanceExpenseInput, + MaintenanceCostItem, +} from '@features/organization/features/maintenance-costs/models'; +import { HlmSelect } from '@shared/ui/select'; +import { MaintenanceExpenseForm } from '../maintenance-expense-form.component'; + +describe('MaintenanceExpenseForm', () => { + let fixture: ComponentFixture; + let submissions: Omit[]; + const original: MaintenanceCostItem = { + id: 'expense:original', + sourceId: 'original', + kind: 'expense', + description: 'Original repair', + currency: 'EUR', + amount: '50.000000', + occurredAt: '2025-01-01T00:00:00Z', + workItemId: 'task', + }; + const root = (): HTMLElement => fixture.nativeElement as HTMLElement; + const setValue = async (id: string, value: string): Promise => { + const control = root().querySelector('#' + id); + if (!control) throw new Error('Missing input ' + id); + control.value = value; + control.dispatchEvent(new Event('input', { bubbles: true })); + await fixture.whenStable(); + }; + const submit = async (): Promise => { + root() + .querySelector('form') + ?.dispatchEvent(new Event('submit', { cancelable: true })); + await fixture.whenStable(); + }; + const chooseOffset = async (value: string): Promise => { + const select: BrnSelect = fixture.debugElement + .query(By.directive(HlmSelect)) + .injector.get(BrnSelect); + select.select(value); + await fixture.whenStable(); + }; + const draft = async ( + amount = '9007199254740993.123456', + date = '2025-01-15T14:30:05', + ): Promise => { + await setValue('maintenance-expense-amount', amount); + await setValue('maintenance-expense-description', ' Repair fee '); + await setValue('maintenance-expense-date', date); + }; + beforeAll(() => { + globalThis.ResizeObserver ??= class { + observe(): void {} + unobserve(): void {} + disconnect(): void {} + } as unknown as typeof ResizeObserver; + }); + beforeEach(async () => { + fixture = TestBed.createComponent(MaintenanceExpenseForm); + submissions = []; + fixture.componentRef.setInput('scope', 'org/intervention'); + fixture.componentRef.setInput('timezone', 'Europe/Paris'); + fixture.componentInstance.submitted.subscribe((value) => submissions.push(value)); + await fixture.whenStable(); + }); + it('emits an exact amount and organization-local actual time as UTC, without a new replay id', async () => { + await draft(); + await submit(); + expect(root().querySelector('hlm-field-error')?.textContent).toBeUndefined(); + expect(submissions).toEqual([ + { + amount: '9007199254740993.123456', + description: 'Repair fee', + incurredAt: '2025-01-15T13:30:05Z', + adjustmentOf: null, + workItemId: null, + }, + ]); + }); + it('requires an original expense for negative amounts and links a correction to its original task', async () => { + await draft('-0.000001'); + await submit(); + expect(submissions).toEqual([]); + fixture.componentRef.setInput('originalExpense', original); + await fixture.whenStable(); + await draft('-0.000001'); + await submit(); + expect(submissions[0]).toMatchObject({ + amount: '-0.000001', + adjustmentOf: 'original', + workItemId: 'task', + }); + }); + it.each(['1.1234567', '1e2', '1,25'])( + 'rejects invalid exact amount %s', + async (value: string) => { + await draft(value); + await submit(); + expect(submissions).toEqual([]); + }, + ); + it.each(['2099-01-01T00:00:00', '2025-03-30T02:30:00'])( + 'rejects future or nonexistent local actual time %s', + async (value: string) => { + await draft('12', value); + await submit(); + expect(submissions).toEqual([]); + expect(root().textContent).toContain('valid actual date'); + expect(root().querySelector('hlm-select')).toBeNull(); + expect(root().querySelector('#maintenance-expense-date')?.value).toBe( + value.slice(0, 16), + ); + }, + ); + it('requires an explicit UTC offset for a repeated organization-local hour', async () => { + await draft('12', '2025-10-26T02:30:00'); + await submit(); + expect(submissions).toEqual([]); + expect(root().textContent).toContain('Choose the UTC offset of the actual expense.'); + expect(root().querySelector('#maintenance-expense-date')?.value).toBe( + '2025-10-26T02:30', + ); + }); + it.each([ + { offset: '+02:00', incurredAt: '2025-10-26T00:30:00Z' }, + { offset: '+01:00', incurredAt: '2025-10-26T01:30:00Z' }, + ])('emits the explicitly selected $offset expense instant', async ({ offset, incurredAt }) => { + await draft('12', '2025-10-26T02:30:00'); + await chooseOffset(offset); + await submit(); + expect(submissions).toEqual([ + { amount: '12', description: 'Repair fee', incurredAt, adjustmentOf: null, workItemId: null }, + ]); + }); + it('requires a new offset decision after the repeated local timestamp changes', async () => { + await draft('12', '2025-10-26T02:30:00'); + await chooseOffset('+02:00'); + await setValue('maintenance-expense-date', '2025-10-26T02:45:05'); + await submit(); + expect(submissions).toEqual([]); + expect(root().querySelector('#maintenance-expense-offset')?.textContent).toContain( + 'Choose the actual UTC offset', + ); + await chooseOffset('+01:00'); + await submit(); + expect(submissions[0]?.incurredAt).toBe('2025-10-26T01:45:05Z'); + }); + it('requires a new offset decision after the organization timezone changes', async () => { + await draft('12', '2025-10-26T02:30:00'); + await chooseOffset('+02:00'); + fixture.componentRef.setInput('timezone', 'Europe/Berlin'); + await fixture.whenStable(); + await submit(); + expect(submissions).toEqual([]); + expect(root().querySelector('#maintenance-expense-offset')?.textContent).toContain( + 'Choose the actual UTC offset', + ); + }); + it('preserves failed drafts, but resets private fields on a scope replacement or confirmed success', async () => { + await draft('42.123456'); + fixture.componentRef.setInput('error', toStoreError(new Error('Rejected'))); + await fixture.whenStable(); + expect(root().querySelector('#maintenance-expense-amount')?.value).toBe( + '42.123456', + ); + fixture.componentRef.setInput('resetToken', 1); + await fixture.whenStable(); + expect(root().querySelector('#maintenance-expense-amount')?.value).toBe(''); + await draft('12'); + fixture.componentRef.setInput('scope', 'other/intervention'); + await fixture.whenStable(); + expect(root().querySelector('#maintenance-expense-amount')?.value).toBe(''); + }); + it.each(['locked', 'pending'])( + 'freezes fields and disallows a new declaration while %s', + async (state: string) => { + await draft('0'); + fixture.componentRef.setInput(state, true); + await fixture.whenStable(); + await submit(); + expect(submissions).toEqual([]); + expect(root().querySelector('#maintenance-expense-amount')?.disabled).toBe( + true, + ); + }, + ); +}); diff --git a/src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-planning-form/index.ts b/src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-planning-form/index.ts new file mode 100644 index 000000000..e418218e7 --- /dev/null +++ b/src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-planning-form/index.ts @@ -0,0 +1,4 @@ +export { + MaintenancePlanningForm, + type MaintenancePlanningIntent, +} from './maintenance-planning-form.component'; diff --git a/src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-planning-form/maintenance-planning-form.component.html b/src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-planning-form/maintenance-planning-form.component.html new file mode 100644 index 000000000..63b32a0cc --- /dev/null +++ b/src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-planning-form/maintenance-planning-form.component.html @@ -0,0 +1,242 @@ +
+
+ Financial preparation +

+ Leave an estimate blank when it is unknown. Zero is a known value. +

+
+
+ + + @if (planningForm.plannedBudget().touched()) { + @for (error of planningForm.plannedBudget().errors(); track error) { + {{ error.message }} + } + } +
+
+ + + @if (planningForm.estimatedMinutes().touched()) { + @for (error of planningForm.estimatedMinutes().errors(); track error) { + {{ error.message }} + } + } +
+
+
+ @for (resource of planningForm.resources; track $index; let index = $index) { +
+ Estimated resource +
+
+ + + + + Work time + Materials + External service + + +
+
+ + + @if (resource.description().touched()) { + @for (error of resource.description().errors(); track error) { + {{ error.message }} + } + } +
+ @if (resource.kind().value() !== 'external') { +
+ + + @if (resource.unitCost().touched()) { + @for (error of resource.unitCost().errors(); track error) { + {{ error.message }} + } + } +
+ } + @if (resource.kind().value() === 'time') { +
+ + + @if (resource.estimatedMinutes().touched()) { + @for (error of resource.estimatedMinutes().errors(); track error) { + {{ error.message }} + } + } +
+ } @else if (resource.kind().value() === 'material') { +
+ + + @if (resource.quantity().touched()) { + @for (error of resource.quantity().errors(); track error) { + {{ error.message }} + } + } +
+ } + @if (resource.kind().value() === 'external') { +
+ + + @if (resource.amount().touched()) { + @for (error of resource.amount().errors(); track error) { + {{ error.message }} + } + } +
+ } +
+ +
+ } + + @if (stale()) { +
+

+ The financial preparation has changed +

+

+ Your edits are retained. Compare them with the refreshed budget before using its latest + version. +

+ +
+ } + @if (error()) { +

{{ error()?.message }}

+ } + +
diff --git a/src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-planning-form/maintenance-planning-form.component.ts b/src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-planning-form/maintenance-planning-form.component.ts new file mode 100644 index 000000000..4ab7d8a79 --- /dev/null +++ b/src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-planning-form/maintenance-planning-form.component.ts @@ -0,0 +1,599 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + input, + output, + signal, + untracked, + type InputSignal, + type OutputEmitterRef, + type Signal, + type WritableSignal, +} from '@angular/core'; +import { + applyEach, + disabled, + form, + FormField, + FormRoot, + maxLength, + required, + validate, + type FieldTree, +} from '@angular/forms/signals'; +import type { StoreError } from '@core/request-state'; +import type { + MaintenanceCostOutput, + WriteMaintenanceCostPlanningInput, +} from '@features/organization/features/maintenance-costs/models'; +import { isMaintenanceAmount } from '@features/organization/features/maintenance-costs/utils'; +import { HlmAlertImports } from '@shared/ui/alert'; +import { HlmButton } from '@shared/ui/button'; +import { HlmFieldImports } from '@shared/ui/field'; +import { HlmInput } from '@shared/ui/input'; +import { HlmSelectImports } from '@shared/ui/select'; +import { HlmSpinner } from '@shared/ui/spinner'; + +/** + * Interface PlanningResourceDraft + * @interface PlanningResourceDraft + * + * @description + * Editable text retains exact decimal precision and existing task associations. + */ +interface PlanningResourceDraft { + /** + * Property kind + * + * @description + * Exact contract value for kind. + * + * @type {'time' | 'material' | 'external'} + */ + kind: 'time' | 'material' | 'external'; + /** + * Property description + * + * @description + * Exact contract value for description. + * + * @type {string} + */ + description: string; + /** + * Property quantity + * + * @description + * Exact contract value for quantity. + * + * @type {string} + */ + quantity: string; + /** + * Property unitCost + * + * @description + * Exact contract value for unitCost. + * + * @type {string} + */ + unitCost: string; + /** + * Property estimatedMinutes + * + * @description + * Exact contract value for estimatedMinutes. + * + * @type {string} + */ + estimatedMinutes: string; + /** + * Property amount + * + * @description + * Exact contract value for amount. + * + * @type {string} + */ + amount: string; + /** + * Property workItemId + * + * @description + * Exact contract value for workItemId. + * + * @type {string | null} + */ + workItemId: string | null; +} +/** + * Interface PlanningDraft + * @interface PlanningDraft + * + * @description + * Draft values use blank strings for an explicit unknown forecast. + */ +interface PlanningDraft { + /** + * Property plannedBudget + * + * @description + * Exact contract value for plannedBudget. + * + * @type {string} + */ + plannedBudget: string; + /** + * Property estimatedMinutes + * + * @description + * Exact contract value for estimatedMinutes. + * + * @type {string} + */ + estimatedMinutes: string; + /** + * Property resources + * + * @description + * Exact contract value for resources. + * + * @type {PlanningResourceDraft[]} + */ + resources: PlanningResourceDraft[]; +} +/** + * Interface MaintenancePlanningIntent + * @interface MaintenancePlanningIntent + * + * @description + * Explicit independent revision accompanies the user's financial preparation. + */ +export interface MaintenancePlanningIntent { + /** + * Property revision + * @readonly + * + * @description + * Exact contract value for revision. + * + * @type {number} + */ + readonly revision: number; + /** + * Property input + * @readonly + * + * @description + * Exact contract value for input. + * + * @type {WriteMaintenanceCostPlanningInput} + */ + readonly input: WriteMaintenanceCostPlanningInput; +} + +/** + * Class MaintenancePlanningForm + * + * @description + * Native financial preparation preserves local edits across refreshes and requires explicit + * adoption of a changed revision. + */ +@Component({ + selector: 'app-maintenance-planning-form', + templateUrl: './maintenance-planning-form.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + FormField, + FormRoot, + HlmButton, + HlmInput, + HlmSpinner, + ...HlmFieldImports, + ...HlmSelectImports, + ...HlmAlertImports, + ], +}) +export class MaintenancePlanningForm { + /** + * Property kindLabelOf + * @readonly + * + * @description + * Supplies localized resource labels to the native select value and its accessible trigger. + * + * @access protected + * @since unreleased + * + * @type {(value: unknown) => string} + */ + protected readonly kindLabelOf: (value: unknown) => string = (value) => { + switch (value) { + case 'time': + return $localize`:@@maintenanceCost.kind.time:Work time`; + case 'external': + return $localize`:@@maintenanceCost.kind.external:External service`; + default: + return $localize`:@@maintenanceCost.kind.material:Materials`; + } + }; + + /** + * Property cost + * @readonly + * + * @description + * Dedicated private financial dossier, including the separate closure snapshot. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly cost: InputSignal = + input.required(); + /** + * Property pending + * @readonly + * + * @description + * An accepted write is awaiting acknowledgement. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly pending: InputSignal = input(false); + /** + * Property locked + * @readonly + * + * @description + * The owning permission, connectivity or replay state prevents editing. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly locked: InputSignal = input(false); + /** + * Property error + * @readonly + * + * @description + * Latest financial error, retained alongside the unchanged local draft. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly error: InputSignal = input(null); + /** + * Property resetToken + * @readonly + * + * @description + * Changes only after confirmed save or replacement of the private session context. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly resetToken: InputSignal = input(0); + /** + * Property submitted + * @readonly + * + * @description + * Validated exact declaration for the owning page to save. + * + * @access public + * @since unreleased + * + * @type {OutputEmitterRef} + */ + public readonly submitted: OutputEmitterRef = + output(); + /** + * Property sourceRevision + * @readonly + * + * @description + * Independent preparation revision associated with the local edits, including initial zero. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal} + */ + protected readonly sourceRevision: WritableSignal = signal(0); + /** + * Property draft + * @readonly + * + * @description + * Editable exact strings; blank forecasts remain explicitly unknown. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal} + */ + protected readonly draft: WritableSignal = signal({ + plannedBudget: '', + estimatedMinutes: '', + resources: [], + }); + /** + * Property stale + * @readonly + * + * @description + * Whether a freshly read preparation revision requires explicit user adoption. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly stale: Signal = computed( + () => this.cost().planningRevision !== this.sourceRevision(), + ); + /** + * Property planningForm + * @readonly + * + * @description + * Native Signal Form with validation and kind-specific resource fields. + * + * @access protected + * @since unreleased + * + * @type {FieldTree} + */ + protected readonly planningForm: FieldTree = form(this.draft, (path) => { + disabled(path, () => this.pending() || this.locked() || !this.cost().planningEditable); + validate(path.plannedBudget, ({ value }) => + !value().trim() || isMaintenanceAmount(value()) ? null : this.amountError(), + ); + validate(path.estimatedMinutes, ({ value }) => + this.validMinutes(value()) ? null : this.minutesError(), + ); + applyEach(path.resources, (row) => { + disabled(row.quantity, ({ valueOf }) => valueOf(row.kind) !== 'material'); + disabled(row.unitCost, ({ valueOf }) => valueOf(row.kind) === 'external'); + disabled(row.estimatedMinutes, ({ valueOf }) => valueOf(row.kind) !== 'time'); + disabled(row.amount, ({ valueOf }) => valueOf(row.kind) !== 'external'); + required(row.description, { + message: $localize`:@@maintenanceCost.planning.descriptionRequired:Describe this estimated resource.`, + }); + maxLength(row.description, 1000); + validate(row.description, ({ value }) => + value().trim() + ? null + : { + kind: 'required', + message: $localize`:@@maintenanceCost.planning.descriptionRequired:Describe this estimated resource.`, + }, + ); + validate(row.quantity, ({ value }) => + !value().trim() || isMaintenanceAmount(value()) ? null : this.amountError(), + ); + validate(row.unitCost, ({ value }) => + !value().trim() || isMaintenanceAmount(value()) ? null : this.amountError(), + ); + validate(row.amount, ({ value }) => + !value().trim() || isMaintenanceAmount(value()) ? null : this.amountError(), + ); + validate(row.estimatedMinutes, ({ value }) => + this.validMinutes(value()) ? null : this.minutesError(), + ); + }); + }); + /** + * Property seededScope + * + * @description + * Last draft initialization identity, preserving edits across ordinary reads and errors. + * + * @access private + * @since unreleased + * + * @type {string} + */ + private seededScope: string = ''; + /** + * Constructor + * @constructor + * + * @description + * Connects native draft initialization and private financial session transitions. + * + * @access public + * @since unreleased + */ + public constructor() { + effect(() => { + const cost = this.cost(), + token = this.resetToken(); + const scope = `${cost.organizationId}/${cost.interventionId}/${token}`; + if (scope === this.seededScope) return; + this.seededScope = scope; + untracked(() => { + this.sourceRevision.set(cost.planningRevision); + this.planningForm().reset({ + plannedBudget: cost.plannedBudget ?? '', + estimatedMinutes: cost.estimatedMinutes?.toString() ?? '', + resources: cost.resources.map((resource) => ({ + kind: resource.kind, + description: resource.description, + quantity: resource.quantity ?? '', + unitCost: resource.unitCost ?? '', + amount: resource.amount ?? '', + estimatedMinutes: resource.estimatedMinutes?.toString() ?? '', + workItemId: resource.workItemId ?? null, + })), + }); + }); + }); + } + /** + * Method addResource + * + * @description + * Adds a blank material estimate while preserving existing resource/task associations. + * + * @access protected + * @since unreleased + * + * @returns {void} No return value. + */ + protected addResource(): void { + if (this.planningForm().disabled() || this.draft().resources.length >= 1000) return; + this.draft.update((value) => ({ + ...value, + resources: [ + ...value.resources, + { + kind: 'material', + description: '', + quantity: '', + unitCost: '', + estimatedMinutes: '', + amount: '', + workItemId: null, + }, + ], + })); + this.planningForm().markAsDirty(); + } + /** + * Method removeResource + * + * @description + * Removes the selected forecast row before any financial fact is recorded. + * + * @access protected + * @since unreleased + * + * @param {number} index - Resource row position. + * + * @returns {void} No return value. + */ + protected removeResource(index: number): void { + if (this.planningForm().disabled()) return; + this.draft.update((value) => ({ + ...value, + resources: value.resources.filter((_, position) => position !== index), + })); + this.planningForm().markAsDirty(); + } + /** + * Method acceptRevision + * + * @description + * Explicitly adopts the refreshed preparation revision while preserving the local values. + * + * @access protected + * @since unreleased + * + * @returns {void} No return value. + */ + protected acceptRevision(): void { + if (!this.planningForm().disabled()) this.sourceRevision.set(this.cost().planningRevision); + } + /** + * Method submit + * + * @description + * Validates the native form and emits exact strings with only the applicable resource tuple. + * + * @access protected + * @since unreleased + * + * @param {Event} event - Native form submission event. + * + * @returns {void} No return value. + */ + protected submit(event: Event): void { + event.preventDefault(); + this.planningForm().markAsTouched(); + if (this.planningForm().invalid() || this.planningForm().disabled() || this.stale()) return; + const value = this.draft(); + this.submitted.emit({ + revision: this.sourceRevision(), + input: { + plannedBudget: value.plannedBudget.trim() || null, + estimatedMinutes: value.estimatedMinutes.trim() ? Number(value.estimatedMinutes) : null, + resources: value.resources.map((resource) => ({ + kind: resource.kind, + description: resource.description.trim(), + workItemId: resource.workItemId, + quantity: resource.kind === 'material' ? resource.quantity.trim() || null : null, + unitCost: resource.kind !== 'external' ? resource.unitCost.trim() || null : null, + estimatedMinutes: + resource.kind === 'time' && resource.estimatedMinutes.trim() + ? Number(resource.estimatedMinutes) + : null, + amount: resource.kind === 'external' ? resource.amount.trim() || null : null, + })), + }, + }); + } + /** + * Method validMinutes + * + * @description + * Validates an optional bounded integral effort estimate independently of monetary strings. + * + * @access private + * @since unreleased + * + * @param {string} value - Exact field text to validate. + * + * @returns {boolean} Validated financial contract result. + */ + private validMinutes(value: string): boolean { + return !value.trim() || (/^\d{1,9}$/.test(value.trim()) && Number(value) <= 100000000); + } + /** + * Method amountError + * + * @description + * Localized message for an invalid nonnegative exact decimal forecast. + * + * @access private + * @since unreleased + * + * @returns {{ kind: string; message: string }} Validated financial contract result. + */ + private amountError(): { kind: string; message: string } { + return { + kind: 'amount', + message: $localize`:@@maintenanceCost.form.exactAmount:Enter a non-negative decimal with at most six decimal places.`, + }; + } + /** + * Method minutesError + * + * @description + * Localized message for an invalid bounded whole-minute forecast. + * + * @access private + * @since unreleased + * + * @returns {{ kind: string; message: string }} Validated financial contract result. + */ + private minutesError(): { kind: string; message: string } { + return { + kind: 'minutes', + message: $localize`:@@maintenanceCost.form.minutes:Enter whole minutes between zero and 100000000.`, + }; + } +} diff --git a/src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-planning-form/testing/maintenance-planning-form.component.spec.ts b/src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-planning-form/testing/maintenance-planning-form.component.spec.ts new file mode 100644 index 000000000..cd71d9d95 --- /dev/null +++ b/src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-planning-form/testing/maintenance-planning-form.component.spec.ts @@ -0,0 +1,174 @@ +import { TestBed, type ComponentFixture } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { BrnSelect } from '@spartan-ng/brain/select'; +import { toStoreError } from '@core/request-state'; +import { maintenanceCostFixture } from '@features/organization/features/maintenance-costs/models/maintenance-cost/testing/maintenance-cost.fixture'; +import { HlmSelect } from '@shared/ui/select'; +import { + MaintenancePlanningForm, + type MaintenancePlanningIntent, +} from '../maintenance-planning-form.component'; + +describe('MaintenancePlanningForm', () => { + let fixture: ComponentFixture; + let submissions: MaintenancePlanningIntent[]; + const root = (): HTMLElement => fixture.nativeElement as HTMLElement; + const setValue = async (id: string, value: string): Promise => { + const control = root().querySelector('#' + id); + if (!control) throw new Error('Missing input ' + id); + control.value = value; + control.dispatchEvent(new Event('input', { bubbles: true })); + await fixture.whenStable(); + }; + const submit = async (): Promise => { + root() + .querySelector('form') + ?.dispatchEvent(new Event('submit', { cancelable: true })); + await fixture.whenStable(); + }; + const kind = async (value: 'time' | 'material' | 'external'): Promise => { + fixture.debugElement + .query(By.directive(HlmSelect)) + .injector.get>(BrnSelect) + .select(value); + await fixture.whenStable(); + }; + beforeEach(async () => { + fixture = TestBed.createComponent(MaintenancePlanningForm); + submissions = []; + fixture.componentRef.setInput('cost', maintenanceCostFixture()); + fixture.componentInstance.submitted.subscribe((value) => submissions.push(value)); + await fixture.whenStable(); + }); + it('emits independent revision zero and distinguishes blank forecast from exact zero', async () => { + await setValue('maintenance-budget', '0'); + await submit(); + expect(submissions).toEqual([ + { revision: 0, input: { plannedBudget: '0', estimatedMinutes: null, resources: [] } }, + ]); + await setValue('maintenance-budget', ''); + await submit(); + expect(submissions[1]?.input.plannedBudget).toBeNull(); + }); + it('retains exact large budgets and refuses excess precision', async () => { + await setValue('maintenance-budget', '9007199254740993.123456'); + await submit(); + expect(submissions[0]?.input.plannedBudget).toBe('9007199254740993.123456'); + await setValue('maintenance-budget', '1.1234567'); + await submit(); + expect(submissions).toHaveLength(1); + expect(root().querySelector('hlm-field-error')?.textContent).toContain('six decimal'); + }); + it('clears inactive resource tuples when changing materials to work time and external service', async () => { + fixture.componentRef.setInput( + 'cost', + maintenanceCostFixture({ + resources: [ + { + kind: 'material', + description: 'Spare part', + quantity: '2', + unitCost: '50.000000', + estimatedMinutes: null, + amount: '100.000000', + workItemId: 'task', + }, + ], + }), + ); + fixture.componentRef.setInput('resetToken', 1); + await fixture.whenStable(); + await kind('time'); + await setValue('maintenance-resource-minutes-0', '60'); + await submit(); + expect(submissions[0]?.input.resources).toEqual([ + { + kind: 'time', + description: 'Spare part', + quantity: null, + unitCost: '50.000000', + estimatedMinutes: 60, + amount: null, + workItemId: 'task', + }, + ]); + await kind('external'); + await setValue('maintenance-resource-amount-0', '7.123456'); + await submit(); + expect(submissions[1]?.input.resources).toEqual([ + { + kind: 'external', + description: 'Spare part', + quantity: null, + unitCost: null, + estimatedMinutes: null, + amount: '7.123456', + workItemId: 'task', + }, + ]); + expect(root().querySelector('#maintenance-resource-quantity-0')).toBeNull(); + expect(root().querySelector('#maintenance-resource-unit-0')).toBeNull(); + }); + it('does not let an invalid hidden previous quantity prevent an external estimate', async () => { + fixture.componentRef.setInput( + 'cost', + maintenanceCostFixture({ + resources: [ + { + kind: 'material', + description: 'Estimate', + quantity: '1', + unitCost: '50', + amount: null, + }, + ], + }), + ); + fixture.componentRef.setInput('resetToken', 1); + await fixture.whenStable(); + await setValue('maintenance-resource-quantity-0', 'invalid'); + await kind('external'); + await setValue('maintenance-resource-amount-0', '0'); + await submit(); + expect(submissions[0]?.input.resources?.[0]).toMatchObject({ + quantity: null, + unitCost: null, + estimatedMinutes: null, + amount: '0', + }); + }); + it('retains rejected edits and requires explicit adoption of the newly read revision', async () => { + await setValue('maintenance-budget', '99.000001'); + fixture.componentRef.setInput('error', toStoreError({ status: 412, title: 'Stale' })); + fixture.componentRef.setInput( + 'cost', + maintenanceCostFixture({ planningRevision: 2, plannedBudget: '40.000000' }), + ); + await fixture.whenStable(); + expect(root().querySelector('#maintenance-budget')?.value).toBe('99.000001'); + await submit(); + expect(submissions).toEqual([]); + [...root().querySelectorAll('button')] + .find((button) => button.textContent?.includes('Use the latest')) + ?.click(); + await fixture.whenStable(); + await submit(); + expect(submissions[0]).toMatchObject({ revision: 2, input: { plannedBudget: '99.000001' } }); + fixture.componentRef.setInput('resetToken', 1); + await fixture.whenStable(); + expect(root().querySelector('#maintenance-budget')?.value).toBe('40.000000'); + }); + it.each(['pending', 'locked'])('prevents writes while %s', async (state: string) => { + fixture.componentRef.setInput(state, true); + await fixture.whenStable(); + await submit(); + expect(submissions).toEqual([]); + expect(root().querySelector('#maintenance-budget')?.disabled).toBe(true); + }); + it('obeys the server closure capability independently of management permission', async () => { + fixture.componentRef.setInput('cost', maintenanceCostFixture({ planningEditable: false })); + await fixture.whenStable(); + await submit(); + expect(submissions).toEqual([]); + }); +}); diff --git a/src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-rate-form/index.ts b/src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-rate-form/index.ts new file mode 100644 index 000000000..1da217abe --- /dev/null +++ b/src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-rate-form/index.ts @@ -0,0 +1 @@ +export { MaintenanceRateForm } from './maintenance-rate-form.component'; diff --git a/src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-rate-form/maintenance-rate-form.component.html b/src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-rate-form/maintenance-rate-form.component.html new file mode 100644 index 000000000..816016215 --- /dev/null +++ b/src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-rate-form/maintenance-rate-form.component.html @@ -0,0 +1,182 @@ +@if (canManage()) { +
+ @if (uncertain()) { +
+
+ } + @if (error(); as failure) { +
+
+ } +
+ Add an hourly rate +

+ A new rate takes effect from its date. Earlier rates and recorded costs stay unchanged. +

+
+
+ + @if (membersAllowed()) { + + + + + + + @for (member of members(); track member.value) { + + + {{ member.displayName }} + @if (member.roleLabel) { + {{ member.roleLabel }} + } + + + } + + + + @if (membersLoading()) { +

+ Loading members… +

+ } @else if (members().length === 0) { +

+ No available members. Refresh the member directory before adding a rate. +

+ } + } @else { +

+ Member directory access is required to choose a member and add a rate. +

+ } + @if (fields.memberId().touched()) { + @for (validation of fields.memberId().errors(); track validation) { + {{ validation.message }} + } + } +
+
+
+ + +

+ {{ currency() || '—' }} + per hour. Use a decimal point and up to six decimal places. +

+ @if (fields.hourlyAmount().touched()) { + @for (validation of fields.hourlyAmount().errors(); track validation) { + {{ validation.message }} + } + } +
+
+ + +

+ This rate applies from the selected calendar day. +

+ @if (fields.effectiveFrom().touched()) { + @for (validation of fields.effectiveFrom().errors(); track validation) { + {{ validation.message }} + } + } +
+
+
+
+ @if (!currency()) { +

+ Set the organization currency before adding a rate. +

+ } +
+ +
+
+} diff --git a/src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-rate-form/maintenance-rate-form.component.ts b/src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-rate-form/maintenance-rate-form.component.ts new file mode 100644 index 000000000..c802d8b37 --- /dev/null +++ b/src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-rate-form/maintenance-rate-form.component.ts @@ -0,0 +1,405 @@ +import { + ChangeDetectionStrategy, + Component, + effect, + input, + output, + signal, + untracked, + type InputSignal, + type OutputEmitterRef, + type WritableSignal, +} from '@angular/core'; +import { disabled, form, FormField, validate, type FieldTree } from '@angular/forms/signals'; +import { NgIcon, provideIcons } from '@ng-icons/core'; +import { lucideCircleAlert } from '@ng-icons/lucide'; +import type { StoreError } from '@core/request-state'; +import type { CreateMaintenanceRateInput } from '@features/organization/features/maintenance-costs/models'; +import { isMaintenanceAmount } from '@features/organization/features/maintenance-costs/utils'; +import type { MemberSelectOption } from '@features/organization/models'; +import { HlmAlertImports } from '@shared/ui/alert'; +import { HlmButton } from '@shared/ui/button'; +import { HlmFieldImports } from '@shared/ui/field'; +import { HlmInput } from '@shared/ui/input'; +import { HlmSelectImports } from '@shared/ui/select'; +import { HlmSpinner } from '@shared/ui/spinner'; + +/** + * Class MaintenanceRateForm + * @class MaintenanceRateForm + * + * @description + * Appends a dated member rate, preserving exact amounts and uncertain declarations for owner + * replay. + */ +@Component({ + selector: 'app-maintenance-rate-form', + templateUrl: './maintenance-rate-form.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + FormField, + NgIcon, + HlmButton, + HlmInput, + HlmSpinner, + ...HlmFieldImports, + ...HlmAlertImports, + ...HlmSelectImports, + ], + providers: [provideIcons({ lucideCircleAlert })], +}) +export class MaintenanceRateForm { + //#region Properties + /** + * Property members + * @readonly + * + * @description + * Authorized human choices whose values are organization member ids. + * + * @access public + * @since 2026-10-06 + * + * @type {InputSignal} + */ + public readonly members: InputSignal = input< + readonly MemberSelectOption[] + >([]); + + /** + * Property membersLoading + * @readonly + * + * @description + * Locks the picker while the owner refreshes available members. + * + * @access public + * @since 2026-10-06 + * + * @type {InputSignal} + */ + public readonly membersLoading: InputSignal = input(false); + + /** + * Property membersAllowed + * @readonly + * + * @description + * Permission to read the directory; false prevents choosing any member. + * + * @access public + * @since 2026-10-06 + * + * @type {InputSignal} + */ + public readonly membersAllowed: InputSignal = input(false); + + /** + * Property canManage + * @readonly + * + * @description + * Effective rate management permission supplied by the owner. + * + * @access public + * @since 2026-10-06 + * + * @type {InputSignal} + */ + public readonly canManage: InputSignal = input(false); + + /** + * Property pending + * @readonly + * + * @description + * Accepted command freezes the complete submitted draft. + * + * @access public + * @since 2026-10-06 + * + * @type {InputSignal} + */ + public readonly pending: InputSignal = input(false); + + /** + * Property uncertain + * @readonly + * + * @description + * Freezes editing until the original rate declaration is acknowledged by the owner. + * + * @access public + * @since 2026-10-06 + * + * @type {InputSignal} + */ + public readonly uncertain: InputSignal = input(false); + + /** + * Property currency + * @readonly + * + * @description + * Confirmed currency displayed beside the exact hourly amount. + * + * @access public + * @since 2026-10-06 + * + * @type {InputSignal} + */ + public readonly currency: InputSignal = input(null); + + /** + * Property error + * @readonly + * + * @description + * Write failure retains all field values for correction or original replay. + * + * @access public + * @since 2026-10-06 + * + * @type {InputSignal} + */ + public readonly error: InputSignal = input(null); + + /** + * Property resetToken + * @readonly + * + * @description + * Incremented only after confirmed success or context replacement, never a rejected write. + * + * @access public + * @since 2026-10-06 + * + * @type {InputSignal} + */ + public readonly resetToken: InputSignal = input(0); + + /** + * Property submitted + * @readonly + * + * @description + * Exact transport values omit the operation identity, which belongs to the store. + * + * @access public + * @since 2026-10-06 + * + * @type {OutputEmitterRef>} + */ + public readonly submitted: OutputEmitterRef> = + output>(); + + /** + * Property retry + * @readonly + * + * @description + * Requests replay of the original store-owned identity and payload. + * + * @access public + * @since 2026-10-06 + * + * @type {OutputEmitterRef} + */ + public readonly retry: OutputEmitterRef = output(); + + /** + * Property draft + * @readonly + * + * @description + * Amount and effective date are strings, without numeric or timezone coercion. + * + * @access protected + * @since 2026-10-06 + * + * @type {WritableSignal>} + */ + protected readonly draft: WritableSignal> = signal({ + memberId: '', + hourlyAmount: '', + effectiveFrom: '', + }); + + /** + * Property errorFallback + * @readonly + * + * @description + * Localized whole-request failure explanation when the server supplies no message. + * + * @access protected + * @since 2026-10-06 + * + * @type {string} + */ + protected readonly errorFallback: string = $localize`:@@maintenanceCost.rate.saveError:Your rate draft is preserved. Try again when the request has settled.`; + + /** + * Property memberLabel + * @readonly + * + * @description + * Resolves only permitted human labels, with no technical identifier fallback. + * + * @access protected + * @since 2026-10-06 + * + * @type {(value: string) => string} + */ + protected readonly memberLabel: (value: string) => string = (value: string): string => + this.membersAllowed() + ? (this.members().find((member: MemberSelectOption) => member.value === value)?.displayName ?? + '') + : ''; + + /** + * Property fields + * @readonly + * + * @description + * Validates authorized membership, exact decimals and a real date-only calendar value. + * + * @access protected + * @since 2026-10-06 + * + * @type {FieldTree>} + */ + protected readonly fields: FieldTree> = form( + this.draft, + (path) => { + disabled( + path, + () => + this.pending() || + this.uncertain() || + !this.canManage() || + !this.membersAllowed() || + this.membersLoading() || + !this.currency(), + ); + validate(path.memberId, ({ value }) => + this.membersAllowed() && + this.members().some((member: MemberSelectOption) => member.value === value()) + ? null + : { + kind: 'member', + message: $localize`:@@maintenanceCost.rate.memberRequired:Choose an available organization member.`, + }, + ); + validate(path.hourlyAmount, ({ value }) => + isMaintenanceAmount(value()) + ? null + : { + kind: 'amount', + message: $localize`:@@maintenanceCost.rate.amountInvalid:Enter a nonnegative amount with no more than six decimal places, using a decimal point.`, + }, + ); + validate(path.effectiveFrom, ({ value }) => + this.validDate(value()) + ? null + : { + kind: 'date', + message: $localize`:@@maintenanceCost.rate.dateRequired:Choose a valid effective date.`, + }, + ); + }, + ); + //#endregion + + //#region Constructor + /** + * Constructor + * @constructor + * + * @description + * Clears fields exclusively when the caller acknowledges a successful submission. + * + * @access public + * @since 2026-10-06 + */ + public constructor() { + effect(() => { + this.resetToken(); + untracked(() => this.fields().reset({ memberId: '', hourlyAmount: '', effectiveFrom: '' })); + }); + } + //#endregion + + //#region Methods + /** + * Method validDate + * @method validDate + * + * @description + * Validates a calendar date arithmetically without parsing an instant or converting timezones. + * + * @access private + * @since 2026-10-06 + * + * @param {string} value - Bare calendar date. + * + * @returns {boolean} Whether the date is real and uses YYYY-MM-DD. + */ + private validDate(value: string): boolean { + if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false; + const year: number = Number(value.slice(0, 4)); + const month: number = Number(value.slice(5, 7)); + const day: number = Number(value.slice(8, 10)); + if (year < 1 || month < 1 || month > 12 || day < 1) return false; + const leap: boolean = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); + const days: readonly number[] = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + return day <= days[month - 1]; + } + + /** + * Method submit + * @method submit + * + * @description + * Emits the original strings only when the owner permits a fresh rate declaration. + * + * @access protected + * @since 2026-10-06 + * + * @param {Event} event - Native submission event. + * + * @returns {void} + */ + protected submit(event: Event): void { + event.preventDefault(); + if ( + this.pending() || + this.uncertain() || + !this.canManage() || + !this.membersAllowed() || + this.membersLoading() || + !this.currency() + ) + return; + this.fields().markAsTouched(); + if (this.fields().invalid()) return; + this.submitted.emit({ ...this.draft(), hourlyAmount: this.draft().hourlyAmount.trim() }); + } + + /** + * Method retryOriginal + * @method retryOriginal + * + * @description + * Emits only replay intent; no new identity or changed draft is submitted. + * + * @access protected + * @since 2026-10-06 + * + * @returns {void} + */ + protected retryOriginal(): void { + if (this.canManage() && this.uncertain() && !this.pending()) this.retry.emit(); + } + //#endregion +} diff --git a/src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-rate-form/testing/maintenance-rate-form.component.spec.ts b/src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-rate-form/testing/maintenance-rate-form.component.spec.ts new file mode 100644 index 000000000..865a3b3b7 --- /dev/null +++ b/src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-rate-form/testing/maintenance-rate-form.component.spec.ts @@ -0,0 +1,191 @@ +import { TestBed, type ComponentFixture } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { BrnSelect } from '@spartan-ng/brain/select'; +import { toStoreError } from '@core/request-state'; +import type { CreateMaintenanceRateInput } from '@features/organization/features/maintenance-costs/models'; +import type { MemberSelectOption } from '@features/organization/models'; +import { HlmSelect } from '@shared/ui/select'; +import { MaintenanceRateForm } from '../maintenance-rate-form.component'; + +describe('MaintenanceRateForm', () => { + let fixture: ComponentFixture; + let submissions: Omit[]; + const member: MemberSelectOption = { + value: 'member-id', + label: 'Alex Martin', + displayName: 'Alex Martin', + roleLabel: 'Technician', + avatarUrl: null, + initials: 'AM', + }; + const root = (): HTMLElement => fixture.nativeElement as HTMLElement; + const setValue = async (id: string, value: string): Promise => { + const control: HTMLInputElement | null = root().querySelector('#' + id); + if (!control) throw new Error('Missing input ' + id); + control.value = value; + control.dispatchEvent(new Event('input', { bubbles: true })); + await fixture.whenStable(); + }; + const chooseMember = async (value: string): Promise => { + const select: BrnSelect = fixture.debugElement + .query(By.directive(HlmSelect)) + .injector.get(BrnSelect); + select.select(value); + await fixture.whenStable(); + }; + const submit = async (): Promise => { + root() + .querySelector('form') + ?.dispatchEvent(new Event('submit', { cancelable: true })); + await fixture.whenStable(); + }; + const validDraft = async (amount = '42.123456', date = '2026-10-25'): Promise => { + await chooseMember(member.value); + await setValue('maintenance-rate-amount', amount); + await setValue('maintenance-rate-effective-from', date); + }; + + beforeEach(async () => { + fixture = TestBed.createComponent(MaintenanceRateForm); + submissions = []; + fixture.componentInstance.submitted.subscribe( + (value: Omit) => submissions.push(value), + ); + fixture.componentRef.setInput('members', [member]); + fixture.componentRef.setInput('membersAllowed', true); + fixture.componentRef.setInput('canManage', true); + fixture.componentRef.setInput('currency', 'EUR'); + await fixture.whenStable(); + }); + + it('chooses a named member and emits exact strings without an operation identity', async () => { + await validDraft('9007199254740993.123456'); + await submit(); + expect(submissions).toEqual([ + { + memberId: member.value, + hourlyAmount: '9007199254740993.123456', + effectiveFrom: '2026-10-25', + }, + ]); + expect(root().querySelector('#maintenance-rate-member')?.textContent).toContain('Alex Martin'); + expect(root().querySelector('input[id*="member"]')).toBeNull(); + }); + + it('accepts explicit zero and a leap-year date', async () => { + await validDraft('0', '2028-02-29'); + await submit(); + expect(submissions).toEqual([ + { memberId: member.value, hourlyAmount: '0', effectiveFrom: '2028-02-29' }, + ]); + }); + + it.each(['-1', '1e2', '1.1234567', '1,25', '', '.5', '01', '1000000000000000000'])( + 'rejects invalid exact amount %s', + async (amount: string) => { + await validDraft(amount); + await submit(); + expect(submissions).toEqual([]); + expect(root().querySelector('hlm-field-error')?.textContent).toContain('nonnegative amount'); + }, + ); + + it('rejects an impossible calendar date', async () => { + await validDraft('12', '2026-02-29'); + await submit(); + expect(submissions).toEqual([]); + expect(root().querySelector('hlm-field-error')?.textContent).toContain('effective date'); + }); + + it('rejects a selection outside the authorized member options', async () => { + await chooseMember('unavailable-id'); + await setValue('maintenance-rate-amount', '12'); + await setValue('maintenance-rate-effective-from', '2026-10-25'); + await submit(); + expect(submissions).toEqual([]); + }); + + it('does not expose a picker or permit submit without directory permission', async () => { + await validDraft(); + fixture.componentRef.setInput('membersAllowed', false); + await fixture.whenStable(); + expect(root().querySelector('hlm-select')).toBeNull(); + expect(root().textContent).not.toContain('Alex Martin'); + await submit(); + expect(submissions).toEqual([]); + }); + + it('has no form or commands without management permission', async () => { + fixture.componentRef.setInput('canManage', false); + await fixture.whenStable(); + expect(root().querySelector('form')).toBeNull(); + expect(root().querySelector('button')).toBeNull(); + }); + + it.each(['pending', 'membersLoading'])('freezes the draft while %s', async (state: string) => { + await validDraft(); + fixture.componentRef.setInput(state, true); + await fixture.whenStable(); + expect(root().querySelector('#maintenance-rate-amount')?.disabled).toBe(true); + expect( + root().querySelector('#maintenance-rate-effective-from')?.disabled, + ).toBe(true); + expect(root().querySelector('#maintenance-rate-member')?.disabled).toBe( + true, + ); + await submit(); + expect(submissions).toEqual([]); + }); + + it('preserves rejected values and resets them only on confirmed success', async () => { + await validDraft(); + fixture.componentRef.setInput('error', toStoreError(new Error('Rate rejected'))); + await fixture.whenStable(); + expect(root().querySelector('#maintenance-rate-amount')?.value).toBe( + '42.123456', + ); + expect(root().querySelector('#maintenance-rate-effective-from')?.value).toBe( + '2026-10-25', + ); + await submit(); + expect(submissions).toHaveLength(1); + fixture.componentRef.setInput('resetToken', 1); + await fixture.whenStable(); + expect(root().querySelector('#maintenance-rate-amount')?.value).toBe(''); + expect(root().querySelector('#maintenance-rate-effective-from')?.value).toBe( + '', + ); + }); + + it('freezes uncertain fields and emits only retry intent for the original declaration', async () => { + await validDraft(); + fixture.componentRef.setInput('uncertain', true); + await fixture.whenStable(); + const retry = vi.fn(); + fixture.componentInstance.retry.subscribe(retry); + expect(root().querySelector('#maintenance-rate-amount')?.disabled).toBe(true); + await submit(); + expect(submissions).toEqual([]); + root().querySelector('[data-testid="maintenance-rate-retry"]')?.click(); + expect(retry).toHaveBeenCalledExactlyOnceWith(undefined); + expect(root().querySelector('#maintenance-rate-amount')?.value).toBe( + '42.123456', + ); + fixture.componentRef.setInput('pending', true); + await fixture.whenStable(); + expect( + root().querySelector('[data-testid="maintenance-rate-retry"]')?.disabled, + ).toBe(true); + }); + + it('requires the confirmed organization currency before new rate creation', async () => { + await validDraft(); + fixture.componentRef.setInput('currency', null); + await fixture.whenStable(); + await submit(); + expect(submissions).toEqual([]); + expect( + root().querySelector('[data-testid="maintenance-rate-submit"]')?.getAttribute('disabled'), + ).not.toBeNull(); + }); +}); diff --git a/src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-report-filter-form/index.ts b/src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-report-filter-form/index.ts new file mode 100644 index 000000000..5572577a9 --- /dev/null +++ b/src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-report-filter-form/index.ts @@ -0,0 +1 @@ +export { MaintenanceReportFilterForm } from './maintenance-report-filter-form.component'; diff --git a/src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-report-filter-form/maintenance-report-filter-form.component.html b/src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-report-filter-form/maintenance-report-filter-form.component.html new file mode 100644 index 000000000..6535c6543 --- /dev/null +++ b/src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-report-filter-form/maintenance-report-filter-form.component.html @@ -0,0 +1,177 @@ +
+
+ Report scope +

+ Choose up to 366 calendar days. The first and last dates are included. +

+
+
+ + + @if (filtersForm.from().touched()) { + @for (error of filtersForm.from().errors(); track error) { + {{ error.message }} + } + } +
+
+ + + @if (filtersForm.to().touched()) { + @for (error of filtersForm.to().errors(); track error) { + {{ error.message }} + } + } +
+
+
+ Group costs by + + + + + +
+
+ @if (hasScope()) { +
+ + Selected filters + +
+ @if (selectedScope().site; as site) { +
+ Site: {{ site.name }} + +
+ } + @if (selectedScope().customer; as customer) { +
+ Customer: + {{ customer.name }} + +
+ } + @if (selectedScope().equipment; as equipment) { +
+ Equipment: + @if (equipment.name || equipment.assetReference) { + {{ equipment.assetReference || equipment.name }} + } @else { + Unnamed equipment + } + + +
+ } + +
+
+ } + +
diff --git a/src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-report-filter-form/maintenance-report-filter-form.component.ts b/src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-report-filter-form/maintenance-report-filter-form.component.ts new file mode 100644 index 000000000..a9091804d --- /dev/null +++ b/src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-report-filter-form/maintenance-report-filter-form.component.ts @@ -0,0 +1,391 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + input, + output, + signal, + untracked, + type InputSignal, + type OutputEmitterRef, + type Signal, + type WritableSignal, +} from '@angular/core'; +import { + disabled, + form, + FormField, + FormRoot, + required, + validate, + type FieldTree, +} from '@angular/forms/signals'; +import { DateTime } from 'luxon'; +import type { + MaintenanceReportGroupBy, + MaintenanceReportQuery, + MaintenanceReportScopeKind, + MaintenanceReportScopeSelection, +} from '@features/organization/features/maintenance-costs/models'; +import { HlmButton } from '@shared/ui/button'; +import { HlmFieldImports } from '@shared/ui/field'; +import { HlmInput } from '@shared/ui/input'; +import { HlmSpinner } from '@shared/ui/spinner'; +import { HlmToggleGroupImports } from '@shared/ui/toggle-group'; + +/** + * Interface ReportFilterDraft + * @interface ReportFilterDraft + * + * @description + * Editable civil dates and one allocation dimension; named identity selections belong to the page. + */ +interface ReportFilterDraft { + /** + * Property from + * + * @description + * Inclusive first civil date without timezone conversion. + * + * @type {string} + */ + from: string; + + /** + * Property to + * + * @description + * Inclusive last civil date without timezone conversion. + * + * @type {string} + */ + to: string; + + /** + * Property groupBy + * + * @description + * Server-supported allocation dimension. + * + * @type {MaintenanceReportGroupBy} + */ + groupBy: MaintenanceReportGroupBy; +} + +/** + * Class MaintenanceReportFilterForm + * @class MaintenanceReportFilterForm + * + * @description + * Emits a bounded economic query while retaining date edits across directory searches and ordinary + * reads. + */ +@Component({ + selector: 'app-maintenance-report-filter-form', + templateUrl: './maintenance-report-filter-form.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + FormField, + FormRoot, + HlmButton, + HlmInput, + HlmSpinner, + ...HlmFieldImports, + ...HlmToggleGroupImports, + ], +}) +export class MaintenanceReportFilterForm { + //#region Properties + /** + * Property query + * @readonly + * + * @description + * Last requested query supplies the initial dates and current server page size. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly query: InputSignal = + input.required(); + + /** + * Property selectedScope + * @readonly + * + * @description + * Named financial-directory identities selected by the page, excluding ordinary resource lookups. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly selectedScope: InputSignal = + input({}); + + /** + * Property scope + * @readonly + * + * @description + * Organization and authenticated-session identity; replacement clears the previous private draft. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly scope: InputSignal = input.required(); + + /** + * Property resetToken + * @readonly + * + * @description + * Explicit page intent to adopt the requested query without discarding drafts on ordinary reads. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly resetToken: InputSignal = input(0); + + /** + * Property pending + * @readonly + * + * @description + * Prevents duplicate query intent while the owning page is reading. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly pending: InputSignal = input(false); + + /** + * Property submitted + * @readonly + * + * @description + * Validated inclusive query starts at page one and carries the current named scope identities. + * + * @access public + * @since unreleased + * + * @type {OutputEmitterRef} + */ + public readonly submitted: OutputEmitterRef = + output(); + + /** + * Property scopeCleared + * @readonly + * + * @description + * Requests removal of one or all named scope selections while preserving date edits. + * + * @access public + * @since unreleased + * + * @type {OutputEmitterRef} + */ + public readonly scopeCleared: OutputEmitterRef = + output(); + + /** + * Property hasScope + * @readonly + * + * @description + * Indicates whether there is an explicit named filter to clear. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly hasScope: Signal = computed(() => { + const selected = this.selectedScope(); + return !!(selected.site || selected.customer || selected.equipment); + }); + + /** + * Property draft + * @readonly + * + * @description + * Local date and grouping edits, independent of directory selection and result pagination. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal} + */ + protected readonly draft: WritableSignal = signal({ + from: '', + to: '', + groupBy: 'equipment', + }); + + /** + * Property filtersForm + * @readonly + * + * @description + * Native Signal Form enforces real civil dates and an inclusive window of at most 366 days. + * + * @access protected + * @since unreleased + * + * @type {FieldTree} + */ + protected readonly filtersForm: FieldTree = form(this.draft, (path) => { + disabled(path, () => this.pending()); + required(path.from, { + message: $localize`:@@maintenanceCost.report.filters.fromRequired:Choose the first report date.`, + }); + required(path.to, { + message: $localize`:@@maintenanceCost.report.filters.toRequired:Choose the last report date.`, + }); + validate(path.from, ({ value }) => + this.validDate(value()) ? null : { kind: 'date', message: this.dateError() }, + ); + validate(path.to, ({ value, valueOf }) => { + if (!this.validDate(value())) return { kind: 'date', message: this.dateError() }; + const start = valueOf(path.from); + if (!this.validDate(start)) return null; + const days = DateTime.fromISO(value(), { zone: 'UTC' }).diff( + DateTime.fromISO(start, { zone: 'UTC' }), + 'days', + ).days; + return days >= 0 && days <= 365 + ? null + : { + kind: 'window', + message: $localize`:@@maintenanceCost.report.filters.window:Choose an ordered date window of at most 366 days, including both dates.`, + }; + }); + validate(path.groupBy, ({ value }) => + ['equipment', 'site', 'customer'].includes(value()) + ? null + : { + kind: 'grouping', + message: $localize`:@@maintenanceCost.report.filters.groupRequired:Choose equipment, site or customer grouping.`, + }, + ); + }); + + /** + * Property seededScope + * + * @description + * Last explicit reset identity, so ordinary query replies never overwrite local edits. + * + * @access private + * @since unreleased + * + * @type {string} + */ + private seededScope: string = ''; + //#endregion + + //#region Constructor + /** + * Constructor + * @constructor + * + * @description + * Adopts initial or explicitly reset query dates at the owning private session boundary. + * + * @access public + * @since unreleased + */ + public constructor() { + effect(() => { + const identity = `${this.scope()}/${this.resetToken()}`; + const query = this.query(); + if (identity === this.seededScope) return; + this.seededScope = identity; + untracked(() => + this.filtersForm().reset({ from: query.from, to: query.to, groupBy: query.groupBy }), + ); + }); + } + //#endregion + + //#region Methods + /** + * Method submit + * @method submit + * + * @description + * Emits validated draft dates with named selected identities and the current page size. + * + * @access protected + * @since unreleased + * + * @param {Event} event - Native form submission event. + * + * @returns {void} No return value. + */ + protected submit(event: Event): void { + event.preventDefault(); + this.filtersForm().markAsTouched(); + if (this.filtersForm().invalid() || this.filtersForm().disabled()) return; + const draft = this.draft(), + selected = this.selectedScope(); + this.submitted.emit({ + ...draft, + page: 1, + itemsPerPage: this.query().itemsPerPage, + ...(selected.site ? { siteId: selected.site.id } : {}), + ...(selected.customer ? { customerId: selected.customer.id } : {}), + ...(selected.equipment ? { equipmentId: selected.equipment.id } : {}), + }); + } + + /** + * Method validDate + * @method validDate + * + * @description + * Rejects invalid or normalized dates before comparing civil days without DST offsets. + * + * @access private + * @since unreleased + * + * @param {string} value - Civil date text supplied by the native field. + * + * @returns {boolean} Whether the value is an exact valid YYYY-MM-DD date. + */ + private validDate(value: string): boolean { + return ( + /^\d{4}-\d{2}-\d{2}$/.test(value) && + DateTime.fromISO(value, { zone: 'UTC' }).toISODate() === value + ); + } + + /** + * Method dateError + * @method dateError + * + * @description + * Supplies the shared localized civil-date validation message. + * + * @access private + * @since unreleased + * + * @returns {string} Field validation message. + */ + private dateError(): string { + return $localize`:@@maintenanceCost.report.filters.date:Choose a valid calendar date.`; + } + //#endregion +} diff --git a/src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-report-filter-form/testing/maintenance-report-filter-form.component.spec.ts b/src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-report-filter-form/testing/maintenance-report-filter-form.component.spec.ts new file mode 100644 index 000000000..5be80b1d3 --- /dev/null +++ b/src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-report-filter-form/testing/maintenance-report-filter-form.component.spec.ts @@ -0,0 +1,184 @@ +import { TestBed, type ComponentFixture } from '@angular/core/testing'; +import type { + MaintenanceReportQuery, + MaintenanceReportScopeKind, +} from '@features/organization/features/maintenance-costs/models'; +import { MaintenanceReportFilterForm } from '../maintenance-report-filter-form.component'; + +describe('MaintenanceReportFilterForm', () => { + let fixture: ComponentFixture; + let submissions: MaintenanceReportQuery[]; + let cleared: MaintenanceReportScopeKind[]; + const initial: MaintenanceReportQuery = { + from: '2026-01-01', + to: '2026-01-31', + groupBy: 'equipment', + page: 3, + itemsPerPage: 25, + siteId: 'previous-site', + }; + const root = (): HTMLElement => fixture.nativeElement as HTMLElement; + const setDate = async (id: string, value: string): Promise => { + const input = root().querySelector('#' + id); + if (!input) throw new Error('Missing date input ' + id); + input.value = value; + input.dispatchEvent(new Event('input', { bubbles: true })); + await fixture.whenStable(); + }; + const submit = async (): Promise => { + root() + .querySelector('form') + ?.dispatchEvent(new Event('submit', { cancelable: true })); + await fixture.whenStable(); + }; + beforeEach(async () => { + fixture = TestBed.createComponent(MaintenanceReportFilterForm); + fixture.componentRef.setInput('query', initial); + fixture.componentRef.setInput('scope', 'organization/session-1'); + submissions = []; + cleared = []; + fixture.componentInstance.submitted.subscribe((value) => submissions.push(value)); + fixture.componentInstance.scopeCleared.subscribe((value) => cleared.push(value)); + await fixture.whenStable(); + }); + + it('emits page one with civil dates and named identities, without displaying UUID controls', async () => { + fixture.componentRef.setInput('selectedScope', { + site: { id: 'site-uuid-private', name: 'Central fire station' }, + customer: { id: 'customer-uuid-private', name: 'Building operator' }, + equipment: { + id: 'equipment-uuid-private', + name: 'Entrance extinguisher', + assetReference: 'EXT-001', + }, + }); + await fixture.whenStable(); + await submit(); + expect(submissions).toEqual([ + { + ...initial, + page: 1, + siteId: 'site-uuid-private', + customerId: 'customer-uuid-private', + equipmentId: 'equipment-uuid-private', + }, + ]); + expect(root().textContent).toContain('Central fire station'); + expect(root().textContent).toContain('Building operator'); + expect(root().textContent).toContain('EXT-001'); + expect(root().textContent).not.toContain('uuid-private'); + expect(root().querySelectorAll('input')).toHaveLength(2); + }); + + it.each([ + ['2024-01-01', '2024-12-31'], + ['2025-03-29', '2025-03-31'], + ['2026-01-01', '2026-01-01'], + ])( + 'accepts the inclusive civil-date boundary %s through %s without DST arithmetic', + async (from: string, to: string) => { + await setDate('maintenance-report-from', from); + await setDate('maintenance-report-to', to); + await submit(); + expect(submissions).toEqual([{ from, to, groupBy: 'equipment', page: 1, itemsPerPage: 25 }]); + }, + ); + + it.each([ + ['2024-01-01', '2025-01-01'], + ['2026-02-01', '2026-01-31'], + ['2025-02-29', '2025-03-01'], + ['', '2026-01-31'], + ])( + 'rejects invalid or excessive civil-date window %s through %s', + async (from: string, to: string) => { + await setDate('maintenance-report-from', from); + await setDate('maintenance-report-to', to); + await submit(); + expect(submissions).toEqual([]); + expect(root().querySelector('hlm-field-error')).not.toBeNull(); + }, + ); + + it('changes grouping through the native non-nullable toggle control', async () => { + const customer = Array.from(root().querySelectorAll('button')).find( + (button) => button.textContent?.trim() === 'Customer', + ); + if (!customer) throw new Error('Missing customer grouping button'); + customer.click(); + await fixture.whenStable(); + await submit(); + expect(submissions[0]?.groupBy).toBe('customer'); + customer.click(); + await fixture.whenStable(); + await submit(); + expect(submissions[1]?.groupBy).toBe('customer'); + }); + + it('retains date drafts through ordinary query and named-scope changes, but clears them on session replacement', async () => { + await setDate('maintenance-report-from', '2026-03-10'); + await setDate('maintenance-report-to', '2026-03-31'); + fixture.componentRef.setInput('query', { + ...initial, + from: '2026-02-01', + to: '2026-02-28', + itemsPerPage: 50, + }); + fixture.componentRef.setInput('selectedScope', { site: { id: 'new-site', name: 'Warehouse' } }); + await fixture.whenStable(); + await submit(); + expect(submissions[0]).toEqual({ + from: '2026-03-10', + to: '2026-03-31', + groupBy: 'equipment', + page: 1, + itemsPerPage: 50, + siteId: 'new-site', + }); + fixture.componentRef.setInput('scope', 'organization/session-2'); + await fixture.whenStable(); + expect(root().querySelector('#maintenance-report-from')?.value).toBe( + '2026-02-01', + ); + expect(root().querySelector('#maintenance-report-to')?.value).toBe( + '2026-02-28', + ); + }); + + it('adopts an explicit reset and emits scope-clear intent without clearing the current draft', async () => { + fixture.componentRef.setInput('selectedScope', { equipment: { id: 'never-show-this' } }); + await fixture.whenStable(); + expect(root().textContent).toContain('Unnamed equipment'); + expect(root().textContent).not.toContain('never-show-this'); + await setDate('maintenance-report-from', '2026-01-10'); + const clear = Array.from(root().querySelectorAll('button')).find( + (button) => button.textContent?.trim() === 'Clear all named filters', + ); + if (!clear) throw new Error('Missing named scope clear action'); + clear.click(); + await fixture.whenStable(); + expect(cleared).toEqual(['all']); + expect(root().querySelector('#maintenance-report-from')?.value).toBe( + '2026-01-10', + ); + fixture.componentRef.setInput('resetToken', 1); + await fixture.whenStable(); + expect(root().querySelector('#maintenance-report-from')?.value).toBe( + initial.from, + ); + }); + + it('disables query intent while pending without losing the editable date text', async () => { + await setDate('maintenance-report-from', '2026-01-10'); + fixture.componentRef.setInput('pending', true); + await fixture.whenStable(); + await submit(); + expect(submissions).toEqual([]); + expect(root().querySelector('#maintenance-report-from')?.disabled).toBe(true); + fixture.componentRef.setInput('pending', false); + await fixture.whenStable(); + expect(root().querySelector('#maintenance-report-from')?.value).toBe( + '2026-01-10', + ); + }); +}); diff --git a/src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-costs-page/maintenance-costs-page.component.html b/src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-costs-page/maintenance-costs-page.component.html new file mode 100644 index 000000000..a2891df2f --- /dev/null +++ b/src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-costs-page/maintenance-costs-page.component.html @@ -0,0 +1,332 @@ +
+
+

+ Prepare budgets and follow internal operational costs. Commercial invoicing remains in your + ERP. +

+ Private financial information +
+ @if (enabled()) { + Open economic pilotage + @if (store.writePending()) { +
+

+ Wait for the current declaration to finish before leaving this page. +

+
+ } + @if (store.journalCallState().status === 'error') { +
+

+ Could not recover previous declarations +

+

+ New declarations stay unavailable until recovery succeeds. +

+ +
+ } + @if (store.uncertainWrite()) { +
+

Confirmation is still needed

+

+ Your original declaration is retained. Retry it unchanged to confirm whether it was saved. +

+ +
+ } + + + + + +
+ @if (store.costCallState().status === 'pending' && !cost()) { +
+
+
+
+ } + @if (store.costCallState().error; as error) { +
+

+ Could not load the financial dossier +

+

{{ error.message }}

+ +
+ } + @if (cost(); as dossier) { +
+

Current realized costs

+ @if (canReadInterventions()) { + Open intervention + } +
+
+
+
+ Planned budget +
+
+ {{ money(dossier.plannedBudget, dossier.currency, locale) }} +
+
+
+
+ Known contributions +
+
+ {{ money(dossier.current.knownTotal, dossier.currency, locale) }} +
+
+
+
+ Complete realized total +
+
+ {{ money(dossier.current.total, dossier.currency, locale) }} +
+
+
+ @if (!dossier.current.complete) { +
+

+ The valuation is incomplete +

+

+ Some prices or hourly rates are unknown. Known contributions are shown separately + and missing values are not treated as zero. +

+
+ } + + @if (canManage()) { + @if (dossier.planningEditable) { + + } @else { +

+ Financial preparation is closed. The recorded budget is retained. +

+ } + @if (planningError()?.code === 412 || planningError()?.code === '412') { + + } + + } + @if (dossier.frozen; as frozen) { +
+
+

Costs recorded at closure

+ Immutable snapshot +
+

+ Later expenses, returns and corrected time affect the current costs while this + original snapshot stays unchanged. +

+
+
+
+ Planned budget +
+
+ {{ money(frozen.plannedBudget, frozen.currency, locale) }} +
+
+
+
+ Captured on +
+
{{ frozen.capturedAt | appOrgDate: 'datetime' }}
+
+
+
+ Known contributions +
+
{{ money(frozen.knownTotal, frozen.currency, locale) }}
+
+
+
+ Complete realized total +
+
+ {{ money(frozen.total, frozen.currency, locale) }} +
+
+
+ @if (!frozen.complete) { +

+ Some valuations were unknown at closure. +

+ } + +
+ } + } @else if (store.costCallState().status === 'idle') { +
+
+

Choose an intervention

+

+ Open an intervention's financial dossier to prepare its budget and follow its costs. +

+
+
+ @if (canReadInterventions()) { + Browse interventions + } +
+
+ } +
+
+ +
+
+ } @else { +

+ Financial information is available only in an authorized browser session. +

+ } +
diff --git a/src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-costs-page/maintenance-costs-page.component.ts b/src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-costs-page/maintenance-costs-page.component.ts new file mode 100644 index 000000000..720f22283 --- /dev/null +++ b/src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-costs-page/maintenance-costs-page.component.ts @@ -0,0 +1,803 @@ +import { isPlatformBrowser } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + inject, + input, + LOCALE_ID, + PLATFORM_ID, + signal, + untracked, + type InputSignal, + type Signal, + type WritableSignal, +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { RouterLink } from '@angular/router'; +import { Events } from '@ngrx/signals/events'; +import { ConnectivityService } from '@core/connectivity'; +import type { StoreError } from '@core/request-state'; +import { AUTH_SESSION_PORT, type AuthSessionPort } from '@features/auth/ports'; +import { OrganizationPermissionService } from '@features/organization/access'; +import type { + CreateMaintenanceExpenseInput, + CreateMaintenanceRateInput, + MaintenanceCostItem, + MaintenanceCostOutput, +} from '@features/organization/features/maintenance-costs/models'; +import { + MaintenanceCostStore, + maintenanceCostStoreEvents, + type MaintenanceCostStoreType, +} from '@features/organization/features/maintenance-costs/state'; +import { formatMaintenanceAmount } from '@features/organization/features/maintenance-costs/utils'; +import { ORGANIZATION_PERMISSION } from '@features/organization/models'; +import { + ORGANIZATION_MEMBER_ACCESS_PORT, + REGIONAL_FORMATTING_PORT, +} from '@features/organization/ports'; +import type { OrganizationMemberAccessPort } from '@features/organization/ports/organization-member-access'; +import { OrgDatePipe, type RegionalFormatSettings } from '@shared/regional-format'; +import { HlmAlertImports } from '@shared/ui/alert'; +import { HlmBadge } from '@shared/ui/badge'; +import { HlmButton } from '@shared/ui/button'; +import { HlmEmptyImports } from '@shared/ui/empty'; +import { HlmSkeleton } from '@shared/ui/skeleton'; +import { HlmTabsImports } from '@shared/ui/tabs'; +import { HlmLarge } from '@shared/ui/typography'; +import type { UnsavedChangesAware } from '@shared/unsaved-changes'; +import { MaintenanceCostFacts } from '../../components/maintenance-cost-facts'; +import { MaintenanceCostSettings } from '../../components/maintenance-cost-settings'; +import { MaintenanceExpenseForm } from '../../forms/maintenance-expense-form'; +import { + MaintenancePlanningForm, + type MaintenancePlanningIntent, +} from '../../forms/maintenance-planning-form'; + +/** + * Class MaintenanceCostsPage + * + * @description + * Owns the private financial route, independent planning revisions and browser/session-scoped + * reads. + */ +@Component({ + selector: 'app-maintenance-costs-page', + templateUrl: './maintenance-costs-page.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [MaintenanceCostStore], + imports: [ + RouterLink, + OrgDatePipe, + HlmBadge, + HlmButton, + HlmLarge, + HlmSkeleton, + MaintenanceCostFacts, + MaintenanceCostSettings, + MaintenanceExpenseForm, + MaintenancePlanningForm, + ...HlmTabsImports, + ...HlmEmptyImports, + ...HlmAlertImports, + ], +}) +export class MaintenanceCostsPage implements UnsavedChangesAware { + /** + * Property renderedScope + * + * @description + * Organization, intervention and authenticated-session identity already rendered. + * + * @access private + * @since unreleased + * + * @type {string} + */ + private renderedScope: string = ''; + /** + * Property organizationId + * @readonly + * + * @description + * Owning organization route parameter. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly organizationId: InputSignal = input.required(); + /** + * Property interventionId + * @readonly + * + * @description + * Optional financial dossier context supplied by the route query. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly interventionId: InputSignal = input(); + /** + * Property store + * @readonly + * + * @description + * Route-scoped browser-only financial state with session fences and stable replay commands. + * + * @access protected + * @since unreleased + * + * @type {MaintenanceCostStoreType} + */ + protected readonly store: MaintenanceCostStoreType = inject(MaintenanceCostStore); + /** + * Property permissions + * @readonly + * + * @description + * Effective API-backed organization permissions. + * + * @access private + * @since unreleased + * + * @type {OrganizationPermissionService} + */ + private readonly permissions: OrganizationPermissionService = inject( + OrganizationPermissionService, + ); + /** + * Property session + * @readonly + * + * @description + * Authenticated session boundary that invalidates private data on account replacement. + * + * @access private + * @since unreleased + * + * @type {AuthSessionPort} + */ + private readonly session: AuthSessionPort = inject(AUTH_SESSION_PORT); + + /** + * Property memberAccess + * @readonly + * + * @description + * Published actor identity clears local drafts even before session cleanup effects flush. + * + * @access private + * @since unreleased + * + * @type {OrganizationMemberAccessPort} + */ + private readonly memberAccess: OrganizationMemberAccessPort = inject( + ORGANIZATION_MEMBER_ACCESS_PORT, + ); + /** + * Property platform + * @readonly + * + * @description + * Angular rendering platform used to prevent financial reads during SSR. + * + * @access private + * @since unreleased + * + * @type {object} + */ + private readonly platform: object = inject(PLATFORM_ID); + /** + * Property connectivity + * @readonly + * + * @description + * Current connectivity used to prohibit creating a new financial declaration offline. + * + * @access private + * @since unreleased + * + * @type {ConnectivityService} + */ + private readonly connectivity: ConnectivityService = inject(ConnectivityService); + /** + * Property locale + * @readonly + * + * @description + * Interface locale for exact decimal grouping. + * + * @access protected + * @since unreleased + * + * @type {string} + */ + protected readonly locale: string = inject(LOCALE_ID); + /** + * Property regionalFormatting + * @readonly + * + * @description + * Organization timezone and date presentation preferences. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly regionalFormatting: Signal = + inject(REGIONAL_FORMATTING_PORT).regionalFormatting; + /** + * Property tab + * @readonly + * + * @description + * Selected financial dossier or organization-settings tab. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal} + */ + protected readonly tab: WritableSignal = signal('costs'); + /** + * Property planningReset + * @readonly + * + * @description + * Confirmed preparation save or context replacement resets this draft. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal} + */ + protected readonly planningReset: WritableSignal = signal(0); + /** + * Property expenseReset + * @readonly + * + * @description + * Confirmed expense acknowledgement or context replacement resets this draft. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal} + */ + protected readonly expenseReset: WritableSignal = signal(0); + /** + * Property currencyReset + * @readonly + * + * @description + * Confirmed currency acknowledgement or context replacement resets this draft. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal} + */ + protected readonly currencyReset: WritableSignal = signal(0); + /** + * Property rateReset + * @readonly + * + * @description + * Confirmed hourly-rate acknowledgement or context replacement resets this draft. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal} + */ + protected readonly rateReset: WritableSignal = signal(0); + /** + * Property adjustment + * @readonly + * + * @description + * Original expense currently selected for a motivated signed correction. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal} + */ + protected readonly adjustment: WritableSignal = + signal(null); + /** + * Property money + * @readonly + * + * @description + * Exact string formatter keeping unknown values distinct from known zero. + * + * @access protected + * @since unreleased + * + * @type {typeof formatMaintenanceAmount} + */ + protected readonly money: typeof formatMaintenanceAmount = formatMaintenanceAmount; + /** + * Property canRead + * @readonly + * + * @description + * Dedicated financial reading permission. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly canRead: Signal = computed(() => + this.permissions.hasPermission(ORGANIZATION_PERMISSION.MAINTENANCE_COST_READ), + ); + /** + * Property enabled + * @readonly + * + * @description + * Private information is available only in an authorized browser session. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly enabled: Signal = computed( + () => isPlatformBrowser(this.platform) && this.session.isAuthenticated() && this.canRead(), + ); + /** + * Property canManage + * @readonly + * + * @description + * Dedicated financial management permission within the current private context. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly canManage: Signal = computed( + () => + this.enabled() && + this.permissions.hasPermission(ORGANIZATION_PERMISSION.MAINTENANCE_COST_MANAGE), + ); + /** + * Property membersAllowed + * @readonly + * + * @description + * Independent member-directory permission required to choose named hourly-rate owners. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly membersAllowed: Signal = computed(() => + this.permissions.hasPermission(ORGANIZATION_PERMISSION.MEMBERS_READ), + ); + /** + * Property canReadInterventions + * @readonly + * + * @description + * Independent work permission controlling links to the intervention routes. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly canReadInterventions: Signal = computed(() => + this.permissions.hasPermission(ORGANIZATION_PERMISSION.INTERVENTIONS_READ), + ); + /** + * Property canWrite + * @readonly + * + * @description + * Authorized online state without an accepted or uncertain financial write. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly canWrite: Signal = computed( + () => + this.canManage() && + this.connectivity.isOnline() && + this.store.journalReady() && + !this.store.writePending() && + !this.store.uncertainWrite(), + ); + /** + * Property cost + * @readonly + * + * @description + * Dedicated private financial dossier, including the separate closure snapshot. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly cost: Signal = computed( + () => this.store.costCallState().data, + ); + /** + * Property planningError + * @readonly + * + * @description + * Failure of the retained preparation command. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly planningError: Signal = computed(() => + this.store.command()?.kind === 'planning' ? this.store.writeCallState().error : null, + ); + /** + * Property expenseError + * @readonly + * + * @description + * Failure of the retained expense command. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly expenseError: Signal = computed(() => + this.store.command()?.kind === 'expense' ? this.store.writeCallState().error : null, + ); + /** + * Property currencyError + * @readonly + * + * @description + * Failure of the retained currency command. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly currencyError: Signal = computed(() => + this.store.command()?.kind === 'currency' ? this.store.writeCallState().error : null, + ); + /** + * Property rateError + * @readonly + * + * @description + * Failure of the retained hourly-rate command. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly rateError: Signal = computed(() => + this.store.command()?.kind === 'rate' ? this.store.writeCallState().error : null, + ); + /** + * Constructor + * @constructor + * + * @description + * Connects native draft initialization and private financial session transitions. + * + * @access public + * @since unreleased + */ + public constructor() { + effect(() => { + const organizationId = this.organizationId(), + interventionId = this.interventionId() ?? null, + revision = this.session.sessionRevision(), + userId = this.memberAccess.profile()?.userId ?? '', + enabled = this.enabled(); + const valid = + !interventionId || + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-7][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test( + interventionId, + ); + untracked(() => { + const identity = `${organizationId}/${interventionId ?? ''}/${revision}/${userId}/${enabled}`; + if (identity !== this.renderedScope) { + this.renderedScope = identity; + this.planningReset.update((value) => value + 1); + this.expenseReset.update((value) => value + 1); + this.currencyReset.update((value) => value + 1); + this.rateReset.update((value) => value + 1); + } + this.adjustment.set(null); + const scope = enabled + ? { + organizationId, + interventionId: valid ? (interventionId?.toLowerCase() ?? null) : null, + sessionRevision: revision, + } + : null; + this.store.setScope(scope); + this.store.readCost(scope); + }); + }); + effect(() => { + const settings = this.tab() === 'settings', + scope = this.store.scope(), + allowed = this.enabled(), + members = this.membersAllowed(); + untracked(() => { + const query = settings && allowed ? scope : null; + this.store.readCurrency(query); + this.store.readRates(query ? { scope: query, page: 1 } : null); + this.store.readMembers(query && members ? query : null); + }); + }); + inject(Events) + .on(maintenanceCostStoreEvents.saved) + .pipe(takeUntilDestroyed()) + .subscribe(({ payload }) => { + if (payload.organizationId !== this.organizationId()) return; + switch (payload.kind) { + case 'planning': + this.planningReset.update((value) => value + 1); + break; + case 'expense': + this.expenseReset.update((value) => value + 1); + this.adjustment.set(null); + break; + case 'currency': + this.currencyReset.update((value) => value + 1); + break; + case 'rate': + this.rateReset.update((value) => value + 1); + break; + } + }); + } + /** + * Method changeTab + * + * @description + * Selects one of the two allowed financial workspace tabs. + * + * @access protected + * @since unreleased + * + * @param {string} tab - Selected tab identifier. + * + * @returns {void} No return value. + */ + protected changeTab(tab: string): void { + if (tab === 'costs' || tab === 'settings') this.tab.set(tab); + } + /** + * Method reloadCost + * + * @description + * Reads the latest current and frozen dossier without resetting unacknowledged edits. + * + * @access protected + * @since unreleased + * + * @returns {void} No return value. + */ + protected reloadCost(): void { + if (this.enabled()) this.store.readCost(this.store.scope()); + } + /** + * Method reloadCurrency + * + * @description + * Reads the latest organization currency and its server lock. + * + * @access protected + * @since unreleased + * + * @returns {void} No return value. + */ + protected reloadCurrency(): void { + if (this.enabled()) this.store.readCurrency(this.store.scope()); + } + /** + * Method reloadRates + * + * @description + * Reads a bounded page of immutable hourly-rate history. + * + * @access protected + * @since unreleased + * + * @param {unknown} page - Requested bounded history page. + * + * @returns {void} No return value. + */ + protected reloadRates(page = this.store.ratePage()): void { + const scope = this.store.scope(); + if (scope && this.enabled()) this.store.readRates({ scope, page }); + } + /** + * Method savePlanning + * + * @description + * Saves exact forecast fields with the independent displayed preparation revision. + * + * @access protected + * @since unreleased + * + * @param {MaintenancePlanningIntent} intent - Validated forecast with its displayed revision. + * + * @returns {void} No return value. + */ + protected savePlanning(intent: MaintenancePlanningIntent): void { + const cost = this.cost(); + if (!cost?.planningEditable || !this.canWrite()) return; + this.store.write({ + kind: 'planning', + organizationId: this.organizationId(), + interventionId: cost.interventionId, + input: intent.input, + revision: intent.revision, + }); + } + /** + * Method saveExpense + * + * @description + * Assigns one stable replay identity to a validated expense or signed adjustment. + * + * @access protected + * @since unreleased + * + * @param {Omit} declaration - Validated exact immutable + * declaration. + * + * @returns {void} No return value. + */ + protected saveExpense(declaration: Omit): void { + const cost = this.cost(); + if (!cost || !this.canWrite()) return; + this.store.write({ + kind: 'expense', + organizationId: this.organizationId(), + interventionId: cost.interventionId, + input: { ...declaration, clientId: crypto.randomUUID() }, + }); + } + /** + * Method saveCurrency + * + * @description + * Updates an unlocked organization currency with financial management permission. + * + * @access protected + * @since unreleased + * + * @param {string} currency - Contract input value. + * + * @returns {void} No return value. + */ + protected saveCurrency(currency: string): void { + if (this.canWrite() && !this.store.currencyCallState().data?.locked) + this.store.write({ kind: 'currency', organizationId: this.organizationId(), currency }); + } + /** + * Method saveRate + * + * @description + * Assigns one stable replay identity to a validated dated hourly rate. + * + * @access protected + * @since unreleased + * + * @param {Omit} declaration - Validated exact immutable + * declaration. + * + * @returns {void} No return value. + */ + protected saveRate(declaration: Omit): void { + if (this.canWrite()) + this.store.write({ + kind: 'rate', + organizationId: this.organizationId(), + input: { ...declaration, clientId: crypto.randomUUID() }, + }); + } + /** + * Method adjust + * + * @description + * Selects the original immutable expense for a motivated signed adjustment. + * + * @access protected + * @since unreleased + * + * @param {MaintenanceCostItem} item - Original operational contribution. + * + * @returns {void} No return value. + */ + protected adjust(item: MaintenanceCostItem): void { + if (this.canWrite()) this.adjustment.set(item); + } + /** + * Method retryWrite + * + * @description + * Replays the retained unchanged declaration to confirm an uncertain server acknowledgement. + * + * @access protected + * @since unreleased + * + * @returns {void} No return value. + */ + protected retryWrite(): void { + if (this.canManage() && this.connectivity.isOnline()) this.store.retryWrite(); + } + + /** + * Method recoverCommands + * @method recoverCommands + * + * @description + * Retries durable journal recovery before permitting a new financial declaration. + * + * @access protected + * @since unreleased + * + * @returns {void} No return value. + */ + protected recoverCommands(): void { + if (this.enabled()) this.store.hydrate(this.store.scope()); + } + + /** + * Method hasUnsavedChanges + * @method hasUnsavedChanges + * + * @description + * Accepted writes retain their live request owner until transmission settles. + * + * @access public + * @since unreleased + * + * @returns {boolean} Whether navigation would destroy an active write. + */ + public hasUnsavedChanges(): boolean { + return this.enabled() && !!this.store.command() && this.store.writePending(); + } + + /** + * Method confirmDeactivation + * @method confirmDeactivation + * + * @description + * Active writes finish before navigation; durable uncertain intentions remain safe to leave. + * + * @access public + * @since unreleased + * + * @returns {Promise} Navigation waits for the current request. + */ + public confirmDeactivation(): Promise { + return Promise.resolve(!this.hasUnsavedChanges()); + } +} diff --git a/src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-costs-page/testing/maintenance-costs-navigation.spec.ts b/src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-costs-page/testing/maintenance-costs-navigation.spec.ts new file mode 100644 index 000000000..3ae8defa4 --- /dev/null +++ b/src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-costs-page/testing/maintenance-costs-navigation.spec.ts @@ -0,0 +1,329 @@ +import { ChangeDetectionStrategy, Component, DestroyRef, PLATFORM_ID, signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { provideRouter, Router, withComponentInputBinding } from '@angular/router'; +import { RouterTestingHarness } from '@angular/router/testing'; +import { of, Subject } from 'rxjs'; +import { ConnectivityService } from '@core/connectivity'; +import { THEME_PORT } from '@core/theme'; +import { AUTH_SESSION_PORT } from '@features/auth/ports'; +import { OrganizationPermissionService } from '@features/organization/access'; +import { OrganizationMemberService } from '@features/organization/data-access'; +import { + MaintenanceCostCommandRepository, + MaintenanceCostService, +} from '@features/organization/features/maintenance-costs/data-access'; +import { maintenanceCostFixture } from '@features/organization/features/maintenance-costs/models/maintenance-cost/testing/maintenance-cost.fixture'; +import { MaintenanceCostStore } from '@features/organization/features/maintenance-costs/state'; +import { ORGANIZATION_PERMISSION } from '@features/organization/models'; +import { + ORGANIZATION_MEMBER_ACCESS_PORT, + REGIONAL_FORMATTING_PORT, +} from '@features/organization/ports'; +import { unsavedChangesGuard } from '@shared/unsaved-changes'; +import { MaintenanceCostSettings } from '../../../components/maintenance-cost-settings'; +import { MaintenanceExpenseForm } from '../../../forms/maintenance-expense-form'; +import { MaintenanceCostsPage } from '../maintenance-costs-page.component'; + +@Component({ template: 'Economic pilotage', changeDetection: ChangeDetectionStrategy.OnPush }) +class EconomicPilotagePage {} + +describe('MaintenanceCostsPage', () => { + beforeAll(() => { + globalThis.ResizeObserver ??= class { + observe(): void {} + unobserve(): void {} + disconnect(): void {} + } as unknown as typeof ResizeObserver; + }); + + it('drains exact expense and organization-rate recovery across dossier navigation before new writes', async () => { + const newIdentity = vi.spyOn(crypto, 'randomUUID'); + const cost = maintenanceCostFixture(); + const otherCost = maintenanceCostFixture({ + id: '12345678-1234-4234-8234-123456789def', + interventionId: '12345678-1234-4234-8234-123456789def', + }); + const confirmedCost = maintenanceCostFixture({ + current: { + total: '9007199254740993.123456', + knownTotal: '9007199254740993.123456', + complete: true, + items: [ + { + id: 'expense-fact', + sourceId: 'expense-fact', + kind: 'expense', + currency: 'EUR', + amount: '9007199254740993.123456', + description: 'Original accepted fee', + occurredAt: '2025-01-01T00:00:00Z', + }, + ], + }, + }); + const transmitted = new Subject>(); + const transmittedRate = new Subject<{ + id: string; + memberId: string; + currency: string; + hourlyAmount: string; + effectiveFrom: string; + replayed: boolean; + }>(); + const rate = { + id: 'rate-fact', + memberId: 'member', + currency: 'EUR', + hourlyAmount: '9007199254740993.123456', + effectiveFrom: '2025-01-01', + replayed: true, + }; + const entries = new Map([['metadata:ownerUserId', 'user-a']]); + const api = { + readCost: vi + .fn() + .mockImplementation((_organizationId, interventionId) => + of(interventionId === otherCost.interventionId ? otherCost : cost), + ), + writePlanning: vi.fn(), + readCurrency: vi + .fn() + .mockReturnValue(of({ organizationId: 'org', currency: 'EUR', locked: true })), + writeCurrency: vi.fn(), + listRates: vi.fn().mockReturnValue(of({ member: [], totalItems: 0 })), + createRate: vi.fn().mockReturnValueOnce(transmittedRate).mockReturnValue(of(rate)), + createExpense: vi.fn().mockReturnValueOnce(transmitted).mockReturnValue(of(confirmedCost)), + }; + TestBed.configureTestingModule({ + providers: [ + provideRouter( + [ + { + path: 'organizations/:organizationId/maintenance-costs/reports', + component: EconomicPilotagePage, + }, + { + path: 'organizations/:organizationId/maintenance-costs', + component: MaintenanceCostsPage, + canDeactivate: [unsavedChangesGuard], + }, + ], + withComponentInputBinding(), + ), + { provide: PLATFORM_ID, useValue: 'browser' }, + { provide: MaintenanceCostService, useValue: api }, + { + provide: OrganizationMemberService, + useValue: { listAll: vi.fn().mockReturnValue(of([])) }, + }, + { + provide: OrganizationPermissionService, + useValue: { + hasPermission: (grant: string) => + [ + ORGANIZATION_PERMISSION.MAINTENANCE_COST_READ, + ORGANIZATION_PERMISSION.MAINTENANCE_COST_MANAGE, + ].includes(grant as typeof ORGANIZATION_PERMISSION.MAINTENANCE_COST_READ), + }, + }, + { + provide: AUTH_SESSION_PORT, + useValue: { isAuthenticated: signal(true), sessionRevision: signal(1) }, + }, + { + provide: ORGANIZATION_MEMBER_ACCESS_PORT, + useValue: { + profile: signal({ userId: 'user-a', organizationId: 'org', isActive: true }), + }, + }, + { provide: THEME_PORT, useValue: { resolvedTheme: signal('light') } }, + { + provide: REGIONAL_FORMATTING_PORT, + useValue: { regionalFormatting: signal({ dateFormat: 'dd/MM/yyyy', timezone: 'UTC' }) }, + }, + { provide: ConnectivityService, useValue: { isOnline: signal(true) } }, + ], + }); + const repository = TestBed.inject(MaintenanceCostCommandRepository); + vi.spyOn(repository, 'get').mockImplementation( + async (store: string, key: string): Promise => + (entries.get(`${store}:${key}`) as T | undefined) ?? null, + ); + const getAll = vi + .spyOn(repository, 'getAll') + .mockImplementation(async (store: string): Promise => + [...entries.entries()] + .filter(([key]) => key.startsWith(`${store}:`)) + .map(([, value]) => structuredClone(value) as T), + ); + vi.spyOn(repository, 'put').mockImplementation(async (store, key, value, current) => { + if (current && !current()) throw new Error('Ownership changed'); + entries.set(`${store}:${key}`, structuredClone(value)); + }); + vi.spyOn(repository, 'remove').mockImplementation(async (store, key, current) => { + if (current && !current()) throw new Error('Ownership changed'); + entries.delete(`${store}:${key}`); + }); + vi.spyOn(repository, 'clearAll').mockImplementation(async () => { + entries.clear(); + }); + const url = `/organizations/org/maintenance-costs?interventionId=${cost.interventionId}`; + const harness = await RouterTestingHarness.create(); + await harness.navigateByUrl(url, MaintenanceCostsPage); + await harness.fixture.whenStable(); + const oldStore = harness.routeDebugElement?.injector.get(MaintenanceCostStore); + if (!oldStore) throw new Error('Cost store missing'); + await vi.waitFor(() => expect(oldStore.journalReady()).toBe(true)); + let destroyed = false; + harness.routeDebugElement?.injector.get(DestroyRef).onDestroy(() => { + destroyed = true; + }); + const form = harness.routeDebugElement?.query(By.directive(MaintenanceExpenseForm)) + .componentInstance as MaintenanceExpenseForm; + form.submitted.emit({ + amount: '9007199254740993.123456', + description: 'Original accepted fee', + incurredAt: '2025-01-01T00:00:00Z', + adjustmentOf: null, + workItemId: null, + }); + await harness.fixture.whenStable(); + await vi.waitFor(() => expect(api.createExpense).toHaveBeenCalledTimes(1)); + expect(oldStore.writePending()).toBe(true); + const pendingLink = harness.routeNativeElement?.querySelector( + 'a[href$="/maintenance-costs/reports"]', + ); + if (!pendingLink) throw new Error('Economic pilotage link missing'); + pendingLink.click(); + await harness.fixture.whenStable(); + expect(TestBed.inject(Router).url).toBe(url); + expect(destroyed).toBe(false); + expect(transmitted.observed).toBe(true); + transmitted.error({ type: 'about:blank', status: 0, title: 'Network', detail: 'Lost reply' }); + await vi.waitFor(() => expect(oldStore.uncertainWrite()).toBe(true)); + expect(oldStore.uncertainWrite()).toBe(true); + const original = structuredClone(api.createExpense.mock.calls[0]?.[2]); + expect(original.clientId).toMatch(/^[\da-f-]{36}$/); + const pilotageLink = harness.routeNativeElement?.querySelector( + 'a[href$="/maintenance-costs/reports"]', + ); + if (!pilotageLink) throw new Error('Economic pilotage link missing'); + pilotageLink.click(); + await harness.fixture.whenStable(); + expect(TestBed.inject(Router).url).toBe('/organizations/org/maintenance-costs/reports'); + expect(destroyed).toBe(true); + const otherUrl = `/organizations/org/maintenance-costs?interventionId=${otherCost.interventionId}`; + await harness.navigateByUrl(otherUrl, MaintenanceCostsPage); + await harness.fixture.whenStable(); + const otherStore = harness.routeDebugElement?.injector.get(MaintenanceCostStore); + await vi.waitFor(() => expect(otherStore?.journalReady()).toBe(true)); + expect(otherStore?.command()).toBeNull(); + expect(otherStore?.costCallState().data?.interventionId).toBe(otherCost.interventionId); + const settingsTab = harness.routeNativeElement?.querySelector( + '[hlmTabsTrigger="settings"]', + ); + if (!settingsTab) throw new Error('Settings tab missing'); + settingsTab.click(); + await harness.fixture.whenStable(); + const settings = harness.routeDebugElement?.query(By.directive(MaintenanceCostSettings)) + .componentInstance as MaintenanceCostSettings; + settings.rateSubmitted.emit({ + memberId: rate.memberId, + hourlyAmount: rate.hourlyAmount, + effectiveFrom: rate.effectiveFrom, + }); + await vi.waitFor(() => expect(api.createRate).toHaveBeenCalledTimes(1)); + transmittedRate.error({ + type: 'about:blank', + status: 0, + title: 'Network', + detail: 'Lost reply', + }); + await vi.waitFor(() => expect(otherStore?.uncertainWrite()).toBe(true)); + const originalRate = structuredClone(api.createRate.mock.calls[0]?.[1]); + expect(originalRate.clientId).toMatch(/^[\da-f-]{36}$/); + expect(originalRate.clientId).not.toBe(original.clientId); + await harness.navigateByUrl( + '/organizations/org/maintenance-costs/reports', + EconomicPilotagePage, + ); + expect([...entries.keys()].filter((key) => key.startsWith('commands:'))).toHaveLength(2); + await harness.navigateByUrl(url, MaintenanceCostsPage); + await harness.fixture.whenStable(); + const restored = harness.routeDebugElement?.injector.get(MaintenanceCostStore); + await vi.waitFor(() => expect(restored?.journalReady()).toBe(true)); + expect(restored).not.toBe(oldStore); + expect(restored?.command()).toMatchObject({ kind: 'expense', input: original }); + expect(restored?.uncertainWrite()).toBe(true); + const currentForm = harness.routeDebugElement?.query(By.directive(MaintenanceExpenseForm)) + .componentInstance as MaintenanceExpenseForm; + currentForm.submitted.emit({ + amount: '1', + description: 'Duplicate attempt', + incurredAt: '2025-01-01T00:00:00Z', + adjustmentOf: null, + workItemId: null, + }); + expect(api.createExpense).toHaveBeenCalledTimes(1); + const retry = [ + ...(harness.routeNativeElement?.querySelectorAll('button') ?? []), + ].find((button) => button.textContent?.includes('Retry the original declaration')); + if (!retry) throw new Error('Exact retry action missing'); + let releaseRecovery!: () => void; + getAll.mockImplementationOnce(async (store: string): Promise => { + await new Promise((resolve) => { + releaseRecovery = resolve; + }); + return [...entries.entries()] + .filter(([key]) => key.startsWith(`${store}:`)) + .map(([, value]) => structuredClone(value) as T); + }); + retry.click(); + await vi.waitFor(() => expect(restored?.writeCallState().status).toBe('success')); + expect(restored?.journalCallState().status).toBe('pending'); + expect(restored?.journalReady()).toBe(false); + expect(restored?.command()).toBeNull(); + expect(restored?.costCallState().data?.current).toEqual(confirmedCost.current); + expect(api.createExpense).toHaveBeenCalledTimes(2); + expect(api.createExpense.mock.calls[1]?.[2]).toEqual(original); + await vi.waitFor(() => expect(releaseRecovery).toBeTypeOf('function')); + expect([...entries.keys()].filter((key) => key.startsWith('commands:'))).toEqual([ + `commands:rate:${originalRate.clientId}`, + ]); + currentForm.submitted.emit({ + amount: '1', + description: 'Premature expense', + incurredAt: '2025-01-01T00:00:00Z', + }); + const restoredSettings = harness.routeDebugElement?.query(By.directive(MaintenanceCostSettings)) + .componentInstance as MaintenanceCostSettings; + restoredSettings.rateSubmitted.emit({ ...originalRate, hourlyAmount: '1' }); + expect(api.createExpense).toHaveBeenCalledTimes(2); + expect(api.createRate).toHaveBeenCalledTimes(1); + expect(newIdentity).toHaveBeenCalledTimes(2); + releaseRecovery(); + await vi.waitFor(() => expect(restored?.command()?.kind).toBe('rate')); + await harness.fixture.whenStable(); + expect(restored?.command()).toMatchObject({ kind: 'rate', input: originalRate }); + expect(restored?.uncertainWrite()).toBe(true); + expect(restored?.costCallState().data?.current).toEqual(confirmedCost.current); + restoredSettings.rateSubmitted.emit({ ...originalRate, hourlyAmount: '1' }); + expect(api.createRate).toHaveBeenCalledTimes(1); + expect(newIdentity).toHaveBeenCalledTimes(2); + const rateRetry = [ + ...(harness.routeNativeElement?.querySelectorAll('button') ?? []), + ].find((button) => button.textContent?.includes('Retry the original declaration')); + if (!rateRetry) throw new Error('Original rate retry action missing'); + rateRetry.click(); + await vi.waitFor(() => expect(restored?.command()).toBeNull()); + await vi.waitFor(() => expect(restored?.journalReady()).toBe(true)); + await harness.fixture.whenStable(); + expect(api.createRate).toHaveBeenCalledTimes(2); + expect(api.createRate.mock.calls[1]?.[1]).toEqual(originalRate); + expect(newIdentity).toHaveBeenCalledTimes(2); + expect(restored?.command()).toBeNull(); + expect(restored?.uncertainWrite()).toBe(false); + expect([...entries.keys()].filter((key) => key.startsWith('commands:'))).toEqual([]); + }); +}); diff --git a/src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-costs-page/testing/maintenance-costs-page.component.spec.ts b/src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-costs-page/testing/maintenance-costs-page.component.spec.ts new file mode 100644 index 000000000..3e996700f --- /dev/null +++ b/src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-costs-page/testing/maintenance-costs-page.component.spec.ts @@ -0,0 +1,314 @@ +import { computed, PLATFORM_ID, signal } from '@angular/core'; +import { TestBed, type ComponentFixture } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { provideRouter } from '@angular/router'; +import { of, Subject, throwError } from 'rxjs'; +import { ConnectivityService } from '@core/connectivity'; +import { THEME_PORT } from '@core/theme'; +import { AUTH_SESSION_PORT } from '@features/auth/ports'; +import { OrganizationPermissionService } from '@features/organization/access'; +import { OrganizationMemberService } from '@features/organization/data-access'; +import { + MaintenanceCostService, + MaintenanceCostCommandRepository, +} from '@features/organization/features/maintenance-costs/data-access'; +import type { MaintenanceCostOutput } from '@features/organization/features/maintenance-costs/models'; +import { maintenanceCostFixture } from '@features/organization/features/maintenance-costs/models/maintenance-cost/testing/maintenance-cost.fixture'; +import { ORGANIZATION_PERMISSION } from '@features/organization/models'; +import { + ORGANIZATION_MEMBER_ACCESS_PORT, + REGIONAL_FORMATTING_PORT, +} from '@features/organization/ports'; +import { MaintenanceExpenseForm } from '../../../forms/maintenance-expense-form'; +import { MaintenancePlanningForm } from '../../../forms/maintenance-planning-form'; +import { MaintenanceCostsPage } from '../maintenance-costs-page.component'; + +describe('MaintenanceCostsPage', () => { + let fixture: ComponentFixture; + let service: { + readCost: ReturnType; + writePlanning: ReturnType; + createExpense: ReturnType; + readCurrency: ReturnType; + writeCurrency: ReturnType; + listRates: ReturnType; + createRate: ReturnType; + }; + let members: { listAll: ReturnType }; + const authenticated = signal(true), + actor = signal('user-a'), + sessionRevision = signal(1), + online = signal(true), + grants = signal([]); + const cost = maintenanceCostFixture(); + const root = (): HTMLElement => fixture.nativeElement as HTMLElement; + const setup = async (platform = 'browser'): Promise => { + TestBed.configureTestingModule({ + imports: [MaintenanceCostsPage], + providers: [ + provideRouter([]), + { provide: PLATFORM_ID, useValue: platform }, + { + provide: ORGANIZATION_MEMBER_ACCESS_PORT, + useValue: { + profile: computed(() => ({ userId: actor(), organizationId: 'org', isActive: true })), + }, + }, + { + provide: MaintenanceCostCommandRepository, + useValue: { + captureOwner: vi.fn().mockImplementation(() => actor()), + readPending: vi.fn().mockResolvedValue(null), + retain: vi.fn().mockResolvedValue(undefined), + acknowledge: vi.fn().mockResolvedValue(undefined), + }, + }, + { provide: MaintenanceCostService, useValue: service }, + { provide: OrganizationMemberService, useValue: members }, + { + provide: OrganizationPermissionService, + useValue: { hasPermission: (value: string) => grants().includes(value) }, + }, + { + provide: AUTH_SESSION_PORT, + useValue: { isAuthenticated: authenticated, sessionRevision }, + }, + { provide: THEME_PORT, useValue: { resolvedTheme: signal('light') } }, + { + provide: REGIONAL_FORMATTING_PORT, + useValue: { regionalFormatting: signal({ dateFormat: 'dd/MM/yyyy', timezone: 'UTC' }) }, + }, + { provide: ConnectivityService, useValue: { isOnline: online } }, + ], + }); + fixture = TestBed.createComponent(MaintenanceCostsPage); + fixture.componentRef.setInput('organizationId', 'org'); + fixture.componentRef.setInput('interventionId', cost.interventionId); + await fixture.whenStable(); + }; + beforeAll(() => { + globalThis.ResizeObserver ??= class { + observe(): void {} + unobserve(): void {} + disconnect(): void {} + } as unknown as typeof ResizeObserver; + }); + beforeEach(() => { + actor.set('user-a'); + authenticated.set(true); + sessionRevision.set(1); + online.set(true); + grants.set([ + ORGANIZATION_PERMISSION.MAINTENANCE_COST_READ, + ORGANIZATION_PERMISSION.MAINTENANCE_COST_MANAGE, + ]); + service = { + readCost: vi.fn().mockReturnValue(of(cost)), + writePlanning: vi.fn().mockReturnValue(of(cost)), + createExpense: vi.fn().mockReturnValue(of(cost)), + readCurrency: vi + .fn() + .mockReturnValue(of({ organizationId: 'org', currency: 'EUR', locked: false })), + writeCurrency: vi + .fn() + .mockReturnValue(of({ organizationId: 'org', currency: 'EUR', locked: false })), + listRates: vi.fn().mockReturnValue(of({ member: [], totalItems: 0 })), + createRate: vi.fn(), + }; + members = { listAll: vi.fn().mockReturnValue(of([])) }; + }); + it.each(['server', 'no-read', 'anonymous'])( + 'performs zero private API calls and renders no financial facts in %s', + async (mode: string) => { + if (mode === 'no-read') grants.set([ORGANIZATION_PERMISSION.MAINTENANCE_COST_MANAGE]); + if (mode === 'anonymous') authenticated.set(false); + await setup(mode === 'server' ? 'server' : 'browser'); + expect(service.readCost).not.toHaveBeenCalled(); + expect(service.readCurrency).not.toHaveBeenCalled(); + expect(service.listRates).not.toHaveBeenCalled(); + expect(members.listAll).not.toHaveBeenCalled(); + expect(root().querySelector('app-maintenance-planning-form')).toBeNull(); + expect(root().textContent).toContain('authorized browser session'); + }, + ); + it('renders a private read-only dossier, exact unknowns and its separate immutable snapshot', async () => { + grants.set([ORGANIZATION_PERMISSION.MAINTENANCE_COST_READ]); + service.readCost.mockReturnValue( + of( + maintenanceCostFixture({ + plannedBudget: '0.000000', + current: { + total: null, + knownTotal: '9007199254740993.123456', + complete: false, + items: [], + }, + frozen: { + version: 1, + capturedAt: '2025-01-01T00:00:00Z', + publicationId: 'publication', + interventionRevision: 8, + currency: 'EUR', + plannedBudget: '20.000000', + estimatedMinutes: 60, + resources: [], + total: '10.000000', + knownTotal: '10.000000', + complete: true, + items: [], + }, + }), + ), + ); + await setup(); + expect(service.readCost).toHaveBeenCalledExactlyOnceWith('org', cost.interventionId); + expect( + root().querySelector('[data-testid="maintenance-current-total"]')?.textContent, + ).toContain('Unknown'); + expect(root().querySelector('[data-testid="maintenance-frozen-total"]')?.textContent).toContain( + '10.000000 EUR', + ); + expect(root().textContent).toContain('9,007,199,254,740,993.123456 EUR'); + expect(root().querySelector('app-maintenance-expense-form')).toBeNull(); + expect(root().querySelector('app-maintenance-planning-form')).toBeNull(); + expect(root().textContent).not.toContain('Browse interventions'); + }); + it('submits the displayed independent revision zero and preserves exact forecast text', async () => { + await setup(); + const form = fixture.debugElement.query(By.directive(MaintenancePlanningForm)) + .componentInstance as MaintenancePlanningForm; + const input = { + plannedBudget: '9007199254740993.123456', + estimatedMinutes: null, + resources: [], + }; + form.submitted.emit({ revision: 0, input }); + await fixture.whenStable(); + expect(service.writePlanning).toHaveBeenCalledExactlyOnceWith( + 'org', + cost.interventionId, + input, + 0, + ); + }); + it('does not read currency/rates until their tab is opened and does not read members without their grant', async () => { + await setup(); + expect(service.readCurrency).not.toHaveBeenCalled(); + expect(service.listRates).not.toHaveBeenCalled(); + const tab = root().querySelector('[hlmTabsTrigger="settings"]'); + if (!tab) throw new Error('Settings tab missing'); + tab.click(); + await fixture.whenStable(); + expect(service.readCurrency).toHaveBeenCalledExactlyOnceWith('org'); + expect(service.listRates).toHaveBeenCalledExactlyOnceWith('org', { page: 1, itemsPerPage: 30 }); + expect(members.listAll).not.toHaveBeenCalled(); + grants.set([...grants(), ORGANIZATION_PERMISSION.MEMBERS_READ]); + await fixture.whenStable(); + expect(members.listAll).toHaveBeenCalledWith('org'); + }); + it('keeps an expense replay key stable after a lost reply and disables a second declaration', async () => { + service.createExpense + .mockReturnValueOnce( + throwError(() => ({ + type: 'about:blank', + status: 0, + title: 'Network', + detail: 'Confirmation lost', + })), + ) + .mockReturnValue(of(cost)); + await setup(); + const form = fixture.debugElement.query(By.directive(MaintenanceExpenseForm)) + .componentInstance as MaintenanceExpenseForm; + const input = { + amount: '12.000001', + description: 'Original repair fee', + incurredAt: '2025-01-01T00:00:00Z', + adjustmentOf: null, + workItemId: null, + }; + form.submitted.emit(input); + await fixture.whenStable(); + expect(service.createExpense).toHaveBeenCalledTimes(1); + expect(root().textContent).toContain('original declaration is retained'); + form.submitted.emit({ ...input, amount: '99' }); + expect(service.createExpense).toHaveBeenCalledTimes(1); + [...root().querySelectorAll('button')] + .find((button) => button.textContent?.includes('Retry the original')) + ?.click(); + await fixture.whenStable(); + expect(service.createExpense).toHaveBeenCalledTimes(2); + expect(service.createExpense.mock.calls[1]?.[2]).toEqual( + service.createExpense.mock.calls[0]?.[2], + ); + expect(service.createExpense.mock.calls[0]?.[2].clientId).toMatch(/^[\da-f-]{36}$/); + }); + it('clears private draft text on a same-organization session replacement', async () => { + await setup(); + const budget = root().querySelector('#maintenance-budget'); + if (!budget) throw new Error('Budget missing'); + budget.value = '123.000001'; + budget.dispatchEvent(new Event('input', { bubbles: true })); + await fixture.whenStable(); + sessionRevision.set(2); + await fixture.whenStable(); + expect(root().querySelector('#maintenance-budget')?.value).toBe(''); + expect(service.readCost).toHaveBeenCalledTimes(2); + }); + it('cancels an obsolete dossier read when route intervention context changes', async () => { + const old = new Subject(); + service.readCost.mockReturnValueOnce(old); + await setup(); + const other = '12345678-1234-4234-8234-123456789abd'; + service.readCost.mockReturnValue(of({ ...cost, id: other, interventionId: other })); + fixture.componentRef.setInput('interventionId', other); + await fixture.whenStable(); + expect(old.observed).toBe(false); + expect(service.readCost).toHaveBeenLastCalledWith('org', other); + }); + + it('clears private draft text on a same-organization actor replacement with unchanged grants', async () => { + await setup(); + const budget = root().querySelector('#maintenance-budget'); + if (!budget) throw new Error('Budget missing'); + budget.value = '123.000001'; + budget.dispatchEvent(new Event('input', { bubbles: true })); + await fixture.whenStable(); + actor.set('user-b'); + await fixture.whenStable(); + expect(root().querySelector('#maintenance-budget')?.value).toBe(''); + expect(service.readCost).toHaveBeenCalledTimes(2); + }); + + it('allows session-loss navigation while an old accepted expense is still pending', async () => { + const accepted = new Subject(); + service.createExpense.mockReturnValueOnce(accepted); + await setup(); + const form = fixture.debugElement.query(By.directive(MaintenanceExpenseForm)) + .componentInstance as MaintenanceExpenseForm; + form.submitted.emit({ + amount: '1', + description: 'Original', + incurredAt: '2025-01-01T00:00:00Z', + adjustmentOf: null, + workItemId: null, + }); + await vi.waitFor(() => expect(service.createExpense).toHaveBeenCalledTimes(1)); + expect(fixture.componentInstance.hasUnsavedChanges()).toBe(true); + authenticated.set(false); + expect(fixture.componentInstance.hasUnsavedChanges()).toBe(false); + expect(await fixture.componentInstance.confirmDeactivation()).toBe(true); + }); + it('prevents offline financial writes while retaining the browser-visible dossier', async () => { + online.set(false); + await setup(); + const form = fixture.debugElement.query(By.directive(MaintenancePlanningForm)) + .componentInstance as MaintenancePlanningForm; + form.submitted.emit({ + revision: 0, + input: { plannedBudget: '1', estimatedMinutes: null, resources: [] }, + }); + expect(service.writePlanning).not.toHaveBeenCalled(); + expect(root().querySelector('#maintenance-budget')?.disabled).toBe(true); + }); +}); diff --git a/src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-reports-page/maintenance-reports-page.component.html b/src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-reports-page/maintenance-reports-page.component.html new file mode 100644 index 000000000..73469c6b6 --- /dev/null +++ b/src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-reports-page/maintenance-reports-page.component.html @@ -0,0 +1,392 @@ +
+
+

+ Compare internal maintenance spending with preparation, by equipment, site or customer. Each + contribution is allocated once and unknown valuations remain visible. +

+
+ Private financial information + Financial dossiers and settings +
+
+ @if (enabled()) { + @if (query(); as filter) { + + } +
+ +
+

+ Search the paginated financial directory to select a named site, customer or equipment. + These minimal contexts are available with financial read permission alone. +

+ +
+
+ @if (store.reportCallState().error; as error) { +
+

+ Could not prepare the financial report +

+

{{ error.message }}

+

+ No partial total is displayed. Check the dates and use a shorter period if the report is + too large. Named target filters refine the selected dossiers; they do not increase the + period's source limit. +

+ @if (query(); as filter) { + + } +
+ } + @if (store.reportCallState().status === 'pending') { +
+ Preparing the financial report +
+
+
+ } + @if (report(); as value) { +
+
+
+

Filtered maintenance totals

+

+ {{ value.from | appOrgDate: 'dateOnly' : regional() }} — + {{ value.to | appOrgDate: 'dateOnly' : regional() }} +

+
+ + Source dossiers: {{ value.interventionCount }} + +
+

+ These totals cover all filtered destinations and unallocated contributions, including + results on other pages. Budget and resource estimates remain separate. +

+

+ Resource estimates provide the planned cost. When a dossier has no estimated resources, + its global budget serves as the forecast. The budget remains a separate metric and is + never added to resource estimates. +

+

+ The inclusive UTC date window selects published dossiers by publication date and open work + by planned start, or creation date when no start is set. Costs show those dossiers' full + current history, including later corrections. +

+
+
+
+ Current realized cost +
+
+ {{ money(value.current.total, value.currency, locale) }} +
+ @if (!value.current.complete) { +
+ Known: {{ money(value.current.knownTotal, value.currency, locale) }} +
+ } +
+
+
+ Recorded at closure +
+
+ {{ money(value.frozen.total, value.currency, locale) }} +
+ @if (!value.frozen.complete) { +
+ Known: {{ money(value.frozen.knownTotal, value.currency, locale) }} +
+ } +
+
+
+ Planned cost +
+
+ {{ money(value.planned.total, value.currency, locale) }} +
+ @if (!value.planned.complete) { +
+ Known: {{ money(value.planned.knownTotal, value.currency, locale) }} +
+ } +
+
+
+ Global budgets +
+
+ {{ money(value.budget.total, value.currency, locale) }} +
+ @if (!value.budget.complete) { +
+ Known: {{ money(value.budget.knownTotal, value.currency, locale) }} +
+ } +
+
+
+ Realized cost minus estimate +
+
+ {{ money(value.variance, value.currency, locale) }} +
+
+
+ @if (!value.current.complete || !value.planned.complete || !value.frozen.complete) { +
+

+ Some valuations are unknown +

+

+ Unknown prices, rates or estimates are not zero. Each incomplete metric shows its + known subtotal, and a variance is available only when both amounts are complete. +

+
+ } + @if (value.missingSnapshotCount > 0) { +
+

+ Some historical closure snapshots are missing +

+

+ Published dossiers without an immutable closure snapshot: + {{ value.missingSnapshotCount }}. Their current facts remain visible, while the + missing closure values are explicit. +

+
+ } +
+
+

Allocated destinations

+ +
+
+

Unallocated contributions

+

+ Whole-dossier budgets, general expenses and unresolved identities remain here. They are + counted once in the filtered totals and are never copied onto every equipment. +

+ +
+
+

Allocation reconciliation

+

+ Original source dossiers equal the selected allocations plus other destinations excluded + by the equipment scope. General unallocated facts stay visible. +

+
+
+
+ Source dossiers, current cost +
+
+ {{ money(value.reconciliation.sourceCurrent.total, value.currency, locale) }} +
+
+
+
+ Other excluded allocations, current cost +
+
+ {{ money(value.reconciliation.excludedCurrent.total, value.currency, locale) }} +
+
+
+
+
+

+ Organization procurement overview +

+

+ This organization-wide overview selects orders created in the date window, independently + of equipment, site or customer filters. Deliveries and supplier returns cover the selected + orders' entire histories. These purchase amounts are separate from consumed-piece costs. +

+
+ @for (metric of procurementMetrics(); track metric.label) { +
+
{{ metric.label }}
+
+ {{ money(metric.amount.total, value.procurement.currency, locale) }} +
+ @if (!metric.amount.complete) { +
+ Known: {{ money(metric.amount.knownTotal, value.procurement.currency, locale) }} +
+ } +
+ } +
+

+ Orders: {{ value.procurement.orderCount }} · Receipts: + {{ value.procurement.receiptCount }} · Receipts awaiting individual equipment: + {{ value.procurement.pendingIndividualizationCount }} +

+
+ + + + +

+ Source financial dossiers +

+

+ These are the exact dossiers contributing to the selected allocation. Open their + private cost dossier to compare current facts with the immutable closure. +

+
+
+ @for (dossier of sourceDossiers(); track dossier.id) { +
+
+

+ {{ dossier.number }} · {{ dossier.name }} +

+ Open financial dossier +
+
+ } +
+
+
+ } + } @else { +

+ Financial information is available only in an authorized browser session. +

+ } +
diff --git a/src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-reports-page/maintenance-reports-page.component.ts b/src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-reports-page/maintenance-reports-page.component.ts new file mode 100644 index 000000000..c6f08b94f --- /dev/null +++ b/src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-reports-page/maintenance-reports-page.component.ts @@ -0,0 +1,683 @@ +import { isPlatformBrowser } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + inject, + input, + LOCALE_ID, + PLATFORM_ID, + signal, + untracked, + type InputSignal, + type Signal, + type WritableSignal, +} from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { DateTime } from 'luxon'; +import { AUTH_SESSION_PORT, type AuthSessionPort } from '@features/auth/ports'; +import { OrganizationPermissionService } from '@features/organization/access'; +import type { + MaintenanceEconomicReportOutput, + MaintenanceEconomicRow, + MaintenanceFinancialDirectoryQuery, + MaintenanceFinancialSourceDossier, + MaintenanceProcurementAmount, + MaintenanceReportQuery, + MaintenanceReportScopeKind, + MaintenanceReportScopeSelection, +} from '@features/organization/features/maintenance-costs/models'; +import { + MaintenanceReportStore, + type MaintenanceReportStoreType, +} from '@features/organization/features/maintenance-costs/state'; +import { formatMaintenanceAmount } from '@features/organization/features/maintenance-costs/utils'; +import { ORGANIZATION_PERMISSION } from '@features/organization/models'; +import { REGIONAL_FORMATTING_PORT } from '@features/organization/ports'; +import { OrgDatePipe, type RegionalFormatSettings } from '@shared/regional-format'; +import { sheetSide } from '@shared/sheet-side'; +import { HlmAlertImports } from '@shared/ui/alert'; +import { HlmBadge } from '@shared/ui/badge'; +import { HlmButton } from '@shared/ui/button'; +import { HlmCollapsibleImports } from '@shared/ui/collapsible'; +import { HlmEmptyImports } from '@shared/ui/empty'; +import { HlmItemImports } from '@shared/ui/item'; +import { HlmSheetImports } from '@shared/ui/sheet'; +import { HlmSkeleton } from '@shared/ui/skeleton'; +import { HlmLarge } from '@shared/ui/typography'; +import { MaintenanceFinancialDirectory } from '../../dataviews/maintenance-financial-directory'; +import { MaintenanceReportFilterForm } from '../../forms/maintenance-report-filter-form'; +import { MaintenanceReportTable } from '../../tables/maintenance-report-table'; + +/** + * Class MaintenanceReportsPage + * @class MaintenanceReportsPage + * + * @description + * Owns read-only economic pilotage, explicit allocation and independent minimal financial directory + * navigation. + */ +@Component({ + selector: 'app-maintenance-reports-page', + templateUrl: './maintenance-reports-page.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [MaintenanceReportStore], + imports: [ + RouterLink, + OrgDatePipe, + HlmBadge, + HlmButton, + HlmLarge, + HlmSkeleton, + MaintenanceFinancialDirectory, + MaintenanceReportFilterForm, + MaintenanceReportTable, + ...HlmCollapsibleImports, + ...HlmEmptyImports, + ...HlmAlertImports, + ...HlmItemImports, + ...HlmSheetImports, + ], +}) +export class MaintenanceReportsPage { + //#region Properties + /** + * Property scopeKey + * @readonly + * + * @description + * Credential-free draft reset key for the current account and organization. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly scopeKey: Signal = computed( + () => `${this.organizationId()}/${this.session.sessionRevision()}`, + ); + + /** + * Property sourceRow + * @readonly + * + * @description + * Explicit allocation whose exact original source dossiers are being inspected. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal} + */ + protected readonly sourceRow: WritableSignal = + signal(null); + + /** + * Property sourceDossiers + * @readonly + * + * @description + * Exact named source subset already bounded and authorized by the confirmed report. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly sourceDossiers: Signal = + computed(() => { + const ids = new Set(this.sourceRow()?.interventionIds ?? []); + return this.report()?.dossiers.filter((source) => ids.has(source.id)) ?? []; + }); + + /** + * Property sourceSide + * @readonly + * + * @description + * Adaptive native sheet placement with comfortable touch behavior. + * + * @access protected + * @since unreleased + * + * @type {Signal<'right' | 'bottom'>} + */ + protected readonly sourceSide: Signal<'right' | 'bottom'> = sheetSide(); + /** + * Property organizationId + * @readonly + * + * @description + * Owning organization route context. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly organizationId: InputSignal = input.required(); + + /** + * Property store + * @readonly + * + * @description + * Route-owned private report and directory state. + * + * @access protected + * @since unreleased + * + * @type {MaintenanceReportStoreType} + */ + protected readonly store: MaintenanceReportStoreType = inject(MaintenanceReportStore); + + /** + * Property session + * @readonly + * + * @description + * Current account identity boundary for private request fencing. + * + * @access private + * @since unreleased + * + * @type {AuthSessionPort} + */ + private readonly session: AuthSessionPort = inject(AUTH_SESSION_PORT); + + /** + * Property permissions + * @readonly + * + * @description + * Effective dedicated financial permissions. + * + * @access private + * @since unreleased + * + * @type {OrganizationPermissionService} + */ + private readonly permissions: OrganizationPermissionService = inject( + OrganizationPermissionService, + ); + + /** + * Property platform + * @readonly + * + * @description + * Rendering platform used to prevent private reads during SSR. + * + * @access private + * @since unreleased + * + * @type {object} + */ + private readonly platform: object = inject(PLATFORM_ID); + + /** + * Property locale + * @readonly + * + * @description + * Interface locale for exact integer and decimal display. + * + * @access protected + * @since unreleased + * + * @type {string} + */ + protected readonly locale: string = inject(LOCALE_ID); + + /** + * Property regional + * @readonly + * + * @description + * Organization civil-date and timezone preferences. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly regional: Signal = + inject(REGIONAL_FORMATTING_PORT).regionalFormatting; + + /** + * Property enabled + * @readonly + * + * @description + * Authorized browser session; finance management alone grants no report access. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly enabled: Signal = computed( + () => + isPlatformBrowser(this.platform) && + this.session.isAuthenticated() && + this.permissions.hasPermission(ORGANIZATION_PERMISSION.MAINTENANCE_COST_READ), + ); + + /** + * Property query + * @readonly + * + * @description + * Requested dates, grouping and scoped filters retained across pagination. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal} + */ + protected readonly query: WritableSignal = + signal(null); + + /** + * Property selectedScope + * @readonly + * + * @description + * Minimal named scope selected from the authorized financial directory. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal} + */ + protected readonly selectedScope: WritableSignal = + signal({}); + + /** + * Property resetToken + * @readonly + * + * @description + * New organization or account reset boundary for editable date drafts. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal} + */ + protected readonly resetToken: WritableSignal = signal(0); + + /** + * Property directoryOpen + * @readonly + * + * @description + * Private directory disclosure state; closed secondary data is not fetched. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal} + */ + protected readonly directoryOpen: WritableSignal = signal(false); + + /** + * Property report + * @readonly + * + * @description + * Confirmed full-scope report with its own dates and pagination metadata. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly report: Signal = computed( + () => this.store.reportCallState().data, + ); + + /** + * Property money + * @readonly + * + * @description + * Exact amount formatter preserving unknown and large six-place values. + * + * @access protected + * @since unreleased + * + * @type {typeof formatMaintenanceAmount} + */ + protected readonly money: typeof formatMaintenanceAmount = formatMaintenanceAmount; + + /** + * Property renderedScope + * + * @description + * Last rendered account and organization identity. + * + * @access private + * @since unreleased + * + * @type {string} + */ + private renderedScope: string = ''; + + /** + * Property procurementMetrics + * @readonly + * + * @description + * Separate procurement metrics; they never add to intervention realized totals. + * + * @access protected + * @since unreleased + * + * @type {Signal< + * readonly { readonly label: string; readonly amount: MaintenanceProcurementAmount }[] + * >} + */ + protected readonly procurementMetrics: Signal< + readonly { readonly label: string; readonly amount: MaintenanceProcurementAmount }[] + > = computed(() => { + const overview = this.report()?.procurement; + return overview + ? [ + { + label: $localize`:@@maintenanceReport.procurement.ordered:Committed purchases`, + amount: overview.ordered, + }, + { + label: $localize`:@@maintenanceReport.procurement.received:Gross deliveries`, + amount: overview.received, + }, + { + label: $localize`:@@maintenanceReport.procurement.outstanding:Still to receive`, + amount: overview.outstanding, + }, + { + label: $localize`:@@maintenanceReport.procurement.returned:Declared supplier returns`, + amount: overview.returned, + }, + ] + : []; + }); + + //#endregion + + //#region Constructor + /** + * Constructor + * @constructor + * + * @description + * Clears private filters and financial facts at every account or organization boundary. + * + * @access public + * @since unreleased + */ + public constructor() { + effect(() => { + const organizationId = this.organizationId(), + sessionRevision = this.session.sessionRevision(), + enabled = this.enabled(); + untracked(() => { + const identity = `${organizationId}/${sessionRevision}/${enabled}`; + if (identity === this.renderedScope) return; + this.renderedScope = identity; + this.store.setScope(enabled ? { organizationId, sessionRevision } : null); + this.selectedScope.set({}); + this.sourceRow.set(null); + this.directoryOpen.set(false); + this.resetToken.update((value) => value + 1); + this.query.set(null); + if (!enabled) return; + const end = DateTime.now().setZone(this.regional().timezone); + const query: MaintenanceReportQuery = { + from: end.minus({ days: 29 }).toFormat('yyyy-MM-dd'), + to: end.toFormat('yyyy-MM-dd'), + groupBy: 'equipment', + page: 1, + itemsPerPage: 30, + }; + this.query.set(query); + this.loadReport(query); + }); + }); + } + //#endregion + + //#region Methods + /** + * Method loadReport + * @method loadReport + * + * @description + * Requests one exact server-calculated allocation page without deriving totals from visible rows. + * + * @access protected + * @since unreleased + * + * @param {MaintenanceReportQuery} query - Bounded date window, grouping, filters and page. + * + * @returns {void} No return value. + */ + protected loadReport(query: MaintenanceReportQuery): void { + const scope = this.store.scope(); + if (!scope || !this.enabled()) return; + this.query.set(query); + this.sourceRow.set(null); + this.store.readReport({ scope, query }); + } + + /** + * Method changePage + * @method changePage + * + * @description + * Keeps the committed date and target scope while requesting another server page. + * + * @access protected + * @since unreleased + * + * @param {number} page - Requested one-based result page. + * + * @returns {void} No return value. + */ + protected changePage(page: number): void { + const query = this.query(); + if (query) this.loadReport({ ...query, page }); + } + + /** + * Method changePageSize + * @method changePageSize + * + * @description + * Returns to the first allocation page after choosing a new bounded result size. + * + * @access protected + * @since unreleased + * + * @param {number} itemsPerPage - Requested page size. + * + * @returns {void} No return value. + */ + protected changePageSize(itemsPerPage: number): void { + const query = this.query(); + if (query) this.loadReport({ ...query, page: 1, itemsPerPage }); + } + + /** + * Method selectScope + * @method selectScope + * + * @description + * Replaces target filters with a consistent named context from one authorized directory entry. + * + * @access protected + * @since unreleased + * + * @param {MaintenanceReportScopeSelection} selected - Explicit named site, customer or equipment + * context. + * + * @returns {void} No return value. + */ + protected selectScope(selected: MaintenanceReportScopeSelection): void { + const query = this.query(); + if (!query || !this.enabled()) return; + this.selectedScope.set(selected); + this.loadReport({ + from: query.from, + to: query.to, + groupBy: query.groupBy, + page: 1, + itemsPerPage: query.itemsPerPage, + ...(selected.site ? { siteId: selected.site.id } : {}), + ...(selected.customer ? { customerId: selected.customer.id } : {}), + ...(selected.equipment ? { equipmentId: selected.equipment.id } : {}), + }); + } + + /** + * Method clearScope + * @method clearScope + * + * @description + * Explicitly clears one target while preserving editable dates and remaining named selections. + * + * @access protected + * @since unreleased + * + * @param {MaintenanceReportScopeKind} kind - Scope dimension to remove, or all dimensions. + * + * @returns {void} No return value. + */ + protected clearScope(kind: MaintenanceReportScopeKind): void { + const selected = { ...this.selectedScope() }; + if (kind === 'all') this.selectScope({}); + else { + delete selected[kind]; + this.selectScope(selected); + } + } + + /** + * Method toggleDirectory + * @method toggleDirectory + * + * @description + * Reads secondary private source identities only after the user opens their disclosure. + * + * @access protected + * @since unreleased + * + * @param {boolean} expanded - Requested disclosure visibility. + * + * @returns {void} No return value. + */ + protected toggleDirectory(expanded: boolean): void { + this.directoryOpen.set(expanded); + if (expanded) this.loadDirectory(this.store.directoryQuery() ?? { page: 1, itemsPerPage: 30 }); + } + + /** + * Method loadDirectory + * @method loadDirectory + * + * @description + * Browses minimal named work sources without ordinary customer, equipment or intervention reads. + * + * @access protected + * @since unreleased + * + * @param {MaintenanceFinancialDirectoryQuery} query - Server-paginated source search and optional + * scope. + * + * @returns {void} No return value. + */ + protected loadDirectory(query: MaintenanceFinancialDirectoryQuery): void { + const scope = this.store.scope(); + if (scope && this.enabled()) this.store.readDirectory({ scope, query }); + } + + /** + * Method searchDirectory + * @method searchDirectory + * + * @description + * Applies the named work search on the server and resets only its pagination. + * + * @access protected + * @since unreleased + * + * @param {string} search - Human-readable work reference or name to find. + * + * @returns {void} No return value. + */ + protected searchDirectory(search: string): void { + this.loadDirectory({ + ...(this.store.directoryQuery() ?? { itemsPerPage: 30 }), + page: 1, + search, + }); + } + + /** + * Method pageDirectory + * @method pageDirectory + * + * @description + * Preserves the directory's server search and optional allocation filters. + * + * @access protected + * @since unreleased + * + * @param {number} page - Requested source page. + * + * @returns {void} No return value. + */ + protected pageDirectory(page: number): void { + this.loadDirectory({ ...(this.store.directoryQuery() ?? { itemsPerPage: 30 }), page }); + } + + /** + * Method resizeDirectory + * @method resizeDirectory + * + * @description + * Chooses another bounded source page size without resetting its search draft. + * + * @access protected + * @since unreleased + * + * @param {number} itemsPerPage - Requested directory page size. + * + * @returns {void} No return value. + */ + protected resizeDirectory(itemsPerPage: number): void { + this.loadDirectory({ ...this.store.directoryQuery(), page: 1, itemsPerPage }); + } + + /** + * Method showSourceDossiers + * @method showSourceDossiers + * + * @description + * Opens the authorized financial source directory for a report allocation destination. + * + * @access protected + * @since unreleased + * + * @param {MaintenanceEconomicRow} row - Allocation destination whose original sources are + * requested. + * + * @returns {void} No return value. + */ + protected showSourceDossiers(row: MaintenanceEconomicRow): void { + if (this.enabled() && this.report()) this.sourceRow.set(row); + } + //#endregion +} diff --git a/src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-reports-page/testing/maintenance-reports-page.component.spec.ts b/src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-reports-page/testing/maintenance-reports-page.component.spec.ts new file mode 100644 index 000000000..b344644c3 --- /dev/null +++ b/src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-reports-page/testing/maintenance-reports-page.component.spec.ts @@ -0,0 +1,308 @@ +import { PLATFORM_ID, signal } from '@angular/core'; +import { TestBed, type ComponentFixture } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { provideRouter } from '@angular/router'; +import { DateTime } from 'luxon'; +import { of, Subject, throwError } from 'rxjs'; +import { INTERACTION_CAPABILITIES_PORT } from '@core/interaction-capabilities'; +import { THEME_PORT } from '@core/theme'; +import { AUTH_SESSION_PORT } from '@features/auth/ports'; +import { OrganizationPermissionService } from '@features/organization/access'; +import { MaintenanceReportService } from '@features/organization/features/maintenance-costs/data-access'; +import type { + MaintenanceEconomicReportOutput, + MaintenanceFinancialDossierOutput, + MaintenanceReportQuery, +} from '@features/organization/features/maintenance-costs/models'; +import { + maintenanceReportFixture, + REPORT_CUSTOMER_ID, + REPORT_EQUIPMENT_ID, + REPORT_INTERVENTION_ID, + REPORT_SITE_ID, +} from '@features/organization/features/maintenance-costs/models/report/testing/maintenance-report.fixture'; +import { ORGANIZATION_PERMISSION } from '@features/organization/models'; +import { REGIONAL_FORMATTING_PORT } from '@features/organization/ports'; +import { MaintenanceFinancialDirectory } from '../../../dataviews/maintenance-financial-directory'; +import { MaintenanceReportFilterForm } from '../../../forms/maintenance-report-filter-form'; +import { MaintenanceReportTable } from '../../../tables/maintenance-report-table'; +import { MaintenanceReportsPage } from '../maintenance-reports-page.component'; + +describe('MaintenanceReportsPage', () => { + let fixture: ComponentFixture; + let api: { readReport: ReturnType; listDossiers: ReturnType }; + const authenticated = signal(true), + sessionRevision = signal(1), + grants = signal([]); + const directoryEntry: MaintenanceFinancialDossierOutput = { + '@id': `/api/organizations/org/maintenance-cost/dossiers/${REPORT_INTERVENTION_ID}`, + '@type': 'MaintenanceFinancialDossier', + id: REPORT_INTERVENTION_ID, + number: 1, + name: 'Entrance repair', + type: 'repair', + status: 'published', + snapshotState: 'available', + identityComplete: true, + site: { id: REPORT_SITE_ID, name: 'Headquarters' }, + customer: { id: REPORT_CUSTOMER_ID, name: 'Internal customer' }, + equipment: [ + { id: REPORT_EQUIPMENT_ID, name: 'Entrance extinguisher', assetReference: 'EX-001' }, + ], + }; + const root = (): HTMLElement => fixture.nativeElement as HTMLElement; + const setup = async (platform = 'browser'): Promise => { + TestBed.configureTestingModule({ + imports: [MaintenanceReportsPage], + providers: [ + provideRouter([]), + { provide: PLATFORM_ID, useValue: platform }, + { provide: MaintenanceReportService, useValue: api }, + { + provide: OrganizationPermissionService, + useValue: { hasPermission: (value: string) => grants().includes(value) }, + }, + { + provide: AUTH_SESSION_PORT, + useValue: { isAuthenticated: authenticated, sessionRevision }, + }, + { provide: THEME_PORT, useValue: { resolvedTheme: signal('light') } }, + { + provide: INTERACTION_CAPABILITIES_PORT, + useValue: { isMobileInteractionMode: signal(false) }, + }, + { + provide: REGIONAL_FORMATTING_PORT, + useValue: { + regionalFormatting: signal({ dateFormat: 'dd/MM/yyyy', timezone: 'Europe/Paris' }), + }, + }, + ], + }); + fixture = TestBed.createComponent(MaintenanceReportsPage); + fixture.componentRef.setInput('organizationId', 'org'); + await fixture.whenStable(); + }; + beforeAll(() => { + globalThis.ResizeObserver ??= class { + observe(): void {} + unobserve(): void {} + disconnect(): void {} + } as unknown as typeof ResizeObserver; + }); + beforeEach(() => { + authenticated.set(true); + sessionRevision.set(1); + grants.set([ORGANIZATION_PERMISSION.MAINTENANCE_COST_READ]); + api = { + readReport: vi + .fn() + .mockImplementation((organizationId: string, query: MaintenanceReportQuery) => + of( + maintenanceReportFixture({ + organizationId, + from: query.from, + to: query.to, + groupBy: query.groupBy, + page: query.page, + itemsPerPage: query.itemsPerPage, + }), + ), + ), + listDossiers: vi.fn().mockReturnValue(of({ member: [directoryEntry], totalItems: 1 })), + }; + }); + it.each(['server', 'anonymous', 'manage-only'])( + 'renders no private report or directory in %s context', + async (mode) => { + if (mode === 'anonymous') authenticated.set(false); + if (mode === 'manage-only') grants.set([ORGANIZATION_PERMISSION.MAINTENANCE_COST_MANAGE]); + await setup(mode === 'server' ? 'server' : 'browser'); + expect(api.readReport).not.toHaveBeenCalled(); + expect(api.listDossiers).not.toHaveBeenCalled(); + expect(root().querySelector('app-maintenance-report-filter-form')).toBeNull(); + expect(root().querySelector('[data-testid="maintenance-report-totals"]')).toBeNull(); + expect(root().textContent).toContain('authorized browser session'); + }, + ); + it('loads one browser report with a civil-date window and leaves the secondary private directory closed', async () => { + await setup(); + expect(api.readReport).toHaveBeenCalledTimes(1); + expect(api.listDossiers).not.toHaveBeenCalled(); + const query = api.readReport.mock.calls[0]?.[1] as MaintenanceReportQuery; + expect( + DateTime.fromISO(query.to, { zone: 'UTC' }).diff( + DateTime.fromISO(query.from, { zone: 'UTC' }), + 'days', + ).days, + ).toBe(29); + expect( + root().querySelector('[data-testid="maintenance-report-current"]')?.textContent, + ).toContain('35.000000 EUR'); + expect( + root().querySelector('[data-testid="maintenance-report-budget"]')?.textContent, + ).toContain('100.000000 EUR'); + expect( + root().querySelector('[data-testid="maintenance-report-planned"]')?.textContent, + ).toContain('24.000000 EUR'); + expect( + root().querySelector('[data-testid="maintenance-report-procurement"]')?.textContent, + ).toContain('independently'); + }); + it('keeps unknown, known large amounts and historical missing snapshots separate from zero', async () => { + api.readReport.mockImplementation((organizationId: string, query: MaintenanceReportQuery) => + of( + maintenanceReportFixture({ + organizationId, + from: query.from, + to: query.to, + current: { + total: null, + knownTotal: '9007199254740993.123456', + complete: false, + contributionCount: 4, + unknownCount: 1, + }, + frozen: { + total: null, + knownTotal: '20.000000', + complete: false, + contributionCount: 2, + unknownCount: 1, + }, + variance: null, + missingSnapshotCount: 1, + }), + ), + ); + await setup(); + expect( + root().querySelector('[data-testid="maintenance-report-current"]')?.textContent, + ).toContain('Unknown'); + expect(root().textContent).toContain('9,007,199,254,740,993.123456 EUR'); + expect( + root().querySelector('[data-testid="maintenance-report-variance"]')?.textContent, + ).toContain('Unknown'); + expect( + root().querySelector('[data-testid="maintenance-report-missing-snapshot"]')?.textContent, + ).toContain('without an immutable closure snapshot'); + }); + it('loads and searches named financial directory pages only after disclosure without ordinary read grants', async () => { + await setup(); + const disclosure = root().querySelector('[hlmCollapsibleTrigger]'); + if (!disclosure) throw new Error('Directory disclosure missing'); + disclosure.click(); + await fixture.whenStable(); + expect(api.listDossiers).toHaveBeenCalledExactlyOnceWith('org', { page: 1, itemsPerPage: 30 }); + const directory = fixture.debugElement.query(By.directive(MaintenanceFinancialDirectory)) + .componentInstance as MaintenanceFinancialDirectory; + directory.searchSubmitted.emit('Entrance'); + await fixture.whenStable(); + expect(api.listDossiers).toHaveBeenLastCalledWith('org', { + page: 1, + itemsPerPage: 30, + search: 'Entrance', + }); + directory.pageChanged.emit(2); + await fixture.whenStable(); + expect(api.listDossiers).toHaveBeenLastCalledWith('org', { + page: 2, + itemsPerPage: 30, + search: 'Entrance', + }); + expect(api.readReport).toHaveBeenCalledTimes(1); + }); + it('replaces target filters with one coherent named equipment context and preserves report pagination scopes', async () => { + await setup(); + root().querySelector('[hlmCollapsibleTrigger]')?.click(); + await fixture.whenStable(); + const directory = fixture.debugElement.query(By.directive(MaintenanceFinancialDirectory)) + .componentInstance as MaintenanceFinancialDirectory; + directory.scopeSelected.emit({ + equipment: directoryEntry.equipment[0], + site: directoryEntry.site ?? undefined, + customer: directoryEntry.customer ?? undefined, + }); + await fixture.whenStable(); + expect(api.readReport.mock.calls.at(-1)?.[1]).toMatchObject({ + equipmentId: REPORT_EQUIPMENT_ID, + siteId: REPORT_SITE_ID, + customerId: REPORT_CUSTOMER_ID, + page: 1, + }); + const table = fixture.debugElement.query(By.directive(MaintenanceReportTable)) + .componentInstance as MaintenanceReportTable; + table.pageChanged.emit(2); + await fixture.whenStable(); + expect(api.readReport.mock.calls.at(-1)?.[1]).toMatchObject({ + equipmentId: REPORT_EQUIPMENT_ID, + siteId: REPORT_SITE_ID, + customerId: REPORT_CUSTOMER_ID, + page: 2, + }); + directory.scopeSelected.emit({ site: { id: 'other-site', name: 'Branch' } }); + await fixture.whenStable(); + const replacement = api.readReport.mock.calls.at(-1)?.[1] as MaintenanceReportQuery; + expect(replacement.siteId).toBe('other-site'); + expect(replacement.equipmentId).toBeUndefined(); + expect(replacement.customerId).toBeUndefined(); + }); + it('keeps rejected oversized queries visible and displays no stale or partial total', async () => { + await setup(); + api.readReport.mockReturnValue( + throwError(() => ({ + status: 422, + title: 'Narrow the scope', + detail: 'More than 500 dossiers.', + })), + ); + const filter = fixture.debugElement.query(By.directive(MaintenanceReportFilterForm)) + .componentInstance as MaintenanceReportFilterForm; + filter.submitted.emit({ + from: '2025-01-01', + to: '2025-12-31', + groupBy: 'site', + page: 1, + itemsPerPage: 30, + }); + await fixture.whenStable(); + expect(root().querySelector('[data-testid="maintenance-report-error"]')?.textContent).toContain( + 'No partial total', + ); + expect(root().querySelector('[data-testid="maintenance-report-totals"]')).toBeNull(); + expect(root().querySelector('app-maintenance-report-filter-form')).not.toBeNull(); + }); + it('shows only exact named source dossiers for the selected unallocated row, without another network request', async () => { + await setup(); + const unallocated = maintenanceReportFixture().unallocated; + const table = fixture.debugElement.queryAll(By.directive(MaintenanceReportTable))[1] + ?.componentInstance as MaintenanceReportTable; + table.dossiersRequested.emit(unallocated); + await fixture.whenStable(); + const sheet = document.querySelector('hlm-sheet-content'); + expect(sheet?.textContent).toContain('Entrance repair'); + expect(sheet?.textContent).toContain('Entrance repair'); + expect(sheet?.textContent).not.toContain('Hall repair'); + expect(sheet?.querySelector('a')?.getAttribute('href')).toContain( + `interventionId=${REPORT_INTERVENTION_ID}`, + ); + expect(api.listDossiers).not.toHaveBeenCalled(); + expect(api.readReport).toHaveBeenCalledTimes(1); + }); + it('cancels a previous private report and resets scopes and drafts at account replacement', async () => { + const old = new Subject(); + api.readReport.mockReturnValueOnce(old); + await setup(); + expect(old.observed).toBe(true); + sessionRevision.set(2); + await fixture.whenStable(); + expect(old.observed).toBe(false); + expect(api.readReport).toHaveBeenCalledTimes(2); + expect( + root().querySelector('[data-testid="maintenance-report-current"]')?.textContent, + ).toContain('35.000000 EUR'); + grants.set([]); + await fixture.whenStable(); + expect(root().querySelector('[data-testid="maintenance-report-totals"]')).toBeNull(); + }); +}); diff --git a/src/app/features/organization/features/maintenance-costs/ui/tables/maintenance-report-table/index.ts b/src/app/features/organization/features/maintenance-costs/ui/tables/maintenance-report-table/index.ts new file mode 100644 index 000000000..c46e22dc8 --- /dev/null +++ b/src/app/features/organization/features/maintenance-costs/ui/tables/maintenance-report-table/index.ts @@ -0,0 +1 @@ +export { MaintenanceReportTable } from './maintenance-report-table.component'; diff --git a/src/app/features/organization/features/maintenance-costs/ui/tables/maintenance-report-table/maintenance-report-table.component.html b/src/app/features/organization/features/maintenance-costs/ui/tables/maintenance-report-table/maintenance-report-table.component.html new file mode 100644 index 000000000..45612cc3a --- /dev/null +++ b/src/app/features/organization/features/maintenance-costs/ui/tables/maintenance-report-table/maintenance-report-table.component.html @@ -0,0 +1,201 @@ +
+ @if (pending()) { +

+ Reading economic allocations +

+ } + @if (rows().length) { +
    + @for (row of rows(); track row.id ?? $index) { +
  • +
    +

    {{ rowLabel(row) }}

    +

    {{ identityLabel(row.identityState) }}

    + @if (!row.allocationComplete) { + Allocation incomplete + } +
    +
    +
    + @for (metric of metrics; track metric.key) { + @let amount = row[metric.key]; +
    +
    {{ metric.label }}
    +
    {{ total(amount) }}
    + @if (!amount.complete) { +
    + Known subtotal{{ money(amount.knownTotal) }}Unvalued contributions: {{ amount.unknownCount }} +
    + } +
    + } +
    +
    + Realized minus forecast +
    +
    {{ money(row.variance) }}
    +
    +
    +
    +
    + Source dossiers: + {{ row.interventionIds.length }} + +
    +
  • + } +
+ +

+ Budget is shown separately from forecast cost. The variance compares current realized cost + with the forecast. +

+ } @else if (!pending()) { +
+
+

+ No costs allocated in this window +

+

+ Choose another date window or review the explicitly unallocated contributions. +

+
+
+ } + @if (showPagination()) { + + } +
diff --git a/src/app/features/organization/features/maintenance-costs/ui/tables/maintenance-report-table/maintenance-report-table.component.ts b/src/app/features/organization/features/maintenance-costs/ui/tables/maintenance-report-table/maintenance-report-table.component.ts new file mode 100644 index 000000000..de15a49f1 --- /dev/null +++ b/src/app/features/organization/features/maintenance-costs/ui/tables/maintenance-report-table/maintenance-report-table.component.ts @@ -0,0 +1,379 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + inject, + input, + LOCALE_ID, + output, + type InputSignal, + type OutputEmitterRef, + type Signal, +} from '@angular/core'; +import type { + MaintenanceEconomicAmount, + MaintenanceEconomicRow, +} from '@features/organization/features/maintenance-costs/models'; +import { formatMaintenanceAmount } from '@features/organization/features/maintenance-costs/utils'; +import { CollectionPagination } from '@shared/collection-pagination'; +import { HlmBadge } from '@shared/ui/badge'; +import { HlmButton } from '@shared/ui/button'; +import { HlmCardImports } from '@shared/ui/card'; +import { HlmEmptyImports } from '@shared/ui/empty'; +import { HlmSpinner } from '@shared/ui/spinner'; +import { HlmTableImports } from '@shared/ui/table'; + +/** + * Interface ReportMetric + * @interface ReportMetric + * + * @description + * One displayed amount category; budgets never merge into forecasts or realized costs. + */ +interface ReportMetric { + /** + * Property key + * + * @description + * Typed projection key on the server's allocation row. + * + * @type {'current' | 'frozen' | 'planned' | 'budget'} + */ + readonly key: 'current' | 'frozen' | 'planned' | 'budget'; + + /** + * Property label + * + * @description + * Localized amount category used by table headers and mobile descriptions. + * + * @type {string} + */ + readonly label: string; +} + +/** + * Class MaintenanceReportTable + * @class MaintenanceReportTable + * + * @description + * Presents server-calculated allocations with exact money and explicit incompleteness, without + * calculating scope totals. + */ +@Component({ + selector: 'app-maintenance-report-table', + templateUrl: './maintenance-report-table.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + CollectionPagination, + HlmBadge, + HlmButton, + HlmSpinner, + ...HlmCardImports, + ...HlmEmptyImports, + ...HlmTableImports, + ], +}) +export class MaintenanceReportTable { + //#region Properties + /** + * Property rows + * @readonly + * + * @description + * Visible server allocation page, or the separately supplied unallocated row. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly rows: InputSignal = input< + readonly MaintenanceEconomicRow[] + >([]); + + /** + * Property currency + * @readonly + * + * @description + * Single organization currency authorized by the dedicated financial report. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly currency: InputSignal = input.required(); + + /** + * Property caption + * @readonly + * + * @description + * Accessible allocation context, including a distinct caption for unallocated contributions. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly caption: InputSignal = input( + $localize`:@@maintenanceCost.report.table.caption:Economic cost allocations`, + ); + + /** + * Property pending + * @readonly + * + * @description + * Indicates a superseding read while previously displayed row values remain intact. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly pending: InputSignal = input(false); + + /** + * Property page + * @readonly + * + * @description + * Actual one-based server allocation page. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly page: InputSignal = input(1); + + /** + * Property itemsPerPage + * @readonly + * + * @description + * Actual result page size supplied by the server report. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly itemsPerPage: InputSignal = input(25); + + /** + * Property totalItems + * @readonly + * + * @description + * Full filtered destination count, independent of the visible row page. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly totalItems: InputSignal = input(0); + + /** + * Property showPagination + * @readonly + * + * @description + * False for the separate unallocated section, which is outside allocated result pagination. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly showPagination: InputSignal = input(true); + + /** + * Property pageChanged + * @readonly + * + * @description + * Requests another server allocation page without estimating totals locally. + * + * @access public + * @since unreleased + * + * @type {OutputEmitterRef} + */ + public readonly pageChanged: OutputEmitterRef = output(); + + /** + * Property pageSizeChanged + * @readonly + * + * @description + * Requests a new bounded server allocation page size. + * + * @access public + * @since unreleased + * + * @type {OutputEmitterRef} + */ + public readonly pageSizeChanged: OutputEmitterRef = output(); + + /** + * Property dossiersRequested + * @readonly + * + * @description + * Requests the named, paginated financial source directory for the selected allocation row. + * + * @access public + * @since unreleased + * + * @type {OutputEmitterRef} + */ + public readonly dossiersRequested: OutputEmitterRef = + output(); + + /** + * Property locale + * @readonly + * + * @description + * Interface locale used for exact integer grouping and the decimal separator. + * + * @access private + * @since unreleased + * + * @type {string} + */ + private readonly locale: string = inject(LOCALE_ID); + + /** + * Property metrics + * @readonly + * + * @description + * Separate realized, frozen, forecast and budget projections shared by both responsive layouts. + * + * @access protected + * @since unreleased + * + * @type {readonly ReportMetric[]} + */ + protected readonly metrics: readonly ReportMetric[] = [ + { + key: 'current', + label: $localize`:@@maintenanceCost.report.table.current:Current realized cost`, + }, + { key: 'frozen', label: $localize`:@@maintenanceCost.report.table.frozen:Cost at closure` }, + { key: 'planned', label: $localize`:@@maintenanceCost.report.table.planned:Forecast cost` }, + { key: 'budget', label: $localize`:@@maintenanceCost.report.table.budget:Budget` }, + ]; + + /** + * Property pageCount + * @readonly + * + * @description + * Navigation count derived solely from server destination count and page size. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly pageCount: Signal = computed(() => + Math.max(1, Math.ceil(this.totalItems() / Math.max(1, this.itemsPerPage()))), + ); + //#endregion + + //#region Methods + /** + * Method money + * @method money + * + * @description + * Formats complete or unknown exact values without converting monetary strings to numbers. + * + * @access protected + * @since unreleased + * + * @param {string | null | undefined} amount - Exact value, or explicitly unknown. + * + * @returns {string} Localized exact amount and currency, or the unknown label. + */ + protected money(amount: string | null | undefined): string { + return formatMaintenanceAmount(amount, this.currency(), this.locale); + } + + /** + * Method total + * @method total + * + * @description + * Keeps incomplete totals unknown even when the known subtotal is zero or positive. + * + * @access protected + * @since unreleased + * + * @param {MaintenanceEconomicAmount} amount - Server valuation completeness and totals. + * + * @returns {string} Complete exact total or an explicit unknown label. + */ + protected total(amount: MaintenanceEconomicAmount): string { + return this.money(amount.complete ? amount.total : null); + } + + /** + * Method rowLabel + * @method rowLabel + * + * @description + * Names an authorized allocation without exposing raw identifiers as fallback labels. + * + * @access protected + * @since unreleased + * + * @param {MaintenanceEconomicRow} row - Server allocation identity and minimal name. + * + * @returns {string} Source name, unallocated label or explicit missing historical identity. + */ + protected rowLabel(row: MaintenanceEconomicRow): string { + if (row.name?.trim()) return row.name; + return row.id + ? $localize`:@@maintenanceCost.report.table.unknownIdentity:Historical identity unavailable` + : $localize`:@@maintenanceCost.report.table.unallocated:Unallocated costs`; + } + + /** + * Method identityLabel + * @method identityLabel + * + * @description + * Explains server identity provenance without implying that current names rewrite historical + * dossiers. + * + * @access protected + * @since unreleased + * + * @param {string} state - Server identity availability and provenance code. + * + * @returns {string} Localized identity provenance, with a safe fallback for unknown codes. + */ + protected identityLabel(state: string): string { + switch (state) { + case 'captured': + return $localize`:@@maintenanceCost.report.table.capturedIdentity:Identity recorded at closure`; + case 'live': + return $localize`:@@maintenanceCost.report.table.liveIdentity:Current identity`; + case 'mixed': + return $localize`:@@maintenanceCost.report.table.mixedIdentity:Current and recorded identity`; + case 'unallocated': + return $localize`:@@maintenanceCost.report.table.unallocatedIdentity:No allocation identity`; + default: + return $localize`:@@maintenanceCost.report.table.incompleteIdentity:Identity is incomplete`; + } + } + //#endregion +} diff --git a/src/app/features/organization/features/maintenance-costs/ui/tables/maintenance-report-table/testing/maintenance-report-table.component.spec.ts b/src/app/features/organization/features/maintenance-costs/ui/tables/maintenance-report-table/testing/maintenance-report-table.component.spec.ts new file mode 100644 index 000000000..e6da65182 --- /dev/null +++ b/src/app/features/organization/features/maintenance-costs/ui/tables/maintenance-report-table/testing/maintenance-report-table.component.spec.ts @@ -0,0 +1,315 @@ +import { LOCALE_ID, provideZonelessChangeDetection } from '@angular/core'; +import { TestBed, type ComponentFixture } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { provideRouter } from '@angular/router'; +import type { + MaintenanceEconomicAmount, + MaintenanceEconomicRow, +} from '@features/organization/features/maintenance-costs/models'; +import { MaintenanceReportTable } from '../maintenance-report-table.component'; + +class ResizeObserverStub { + public observe(): void {} + public unobserve(): void {} + public disconnect(): void {} +} + +function amount(total = '0.000000'): MaintenanceEconomicAmount { + return { + total, + knownTotal: total, + complete: true, + contributionCount: 1, + unknownCount: 0, + }; +} + +function row(overrides: Partial = {}): MaintenanceEconomicRow { + return { + id: '436c3a9d-a3a4-4bd5-81f3-1b291eef6197', + name: 'Extinguisher reception desk', + identityState: 'captured', + allocationComplete: true, + current: amount('120.000000'), + frozen: amount('115.000000'), + planned: amount('100.000000'), + budget: amount('150.000000'), + variance: '20.000000', + interventionIds: ['6836d082-7860-4791-9282-ff075c7b5427'], + ...overrides, + }; +} + +function text(element: Element | null): string { + return element?.textContent?.replace(/\s+/g, ' ').trim() ?? ''; +} + +describe('MaintenanceReportTable', () => { + let fixture: ComponentFixture; + let dossierRequests: MaintenanceEconomicRow[]; + let pageRequests: number[]; + let pageSizeRequests: number[]; + + const root = (): HTMLElement => fixture.nativeElement as HTMLElement; + const cardMetric = (label: string, index = 0): HTMLElement => { + const card = root().querySelectorAll('[data-testid="maintenance-report-cards"] li')[index]; + const term = Array.from(card?.querySelectorAll('dt') ?? []).find( + (element) => text(element) === label, + ); + const container = term?.parentElement; + if (!container) throw new Error('Missing card metric ' + label); + return container; + }; + const gridCells = (index = 0): HTMLTableCellElement[] => { + const rows = root().querySelectorAll('[data-testid="maintenance-report-grid"] tbody tr'); + return Array.from(rows[index]?.querySelectorAll('td') ?? []); + }; + + beforeAll(() => { + globalThis.ResizeObserver ??= ResizeObserverStub as unknown as typeof ResizeObserver; + }); + + beforeEach(async () => { + TestBed.configureTestingModule({ + providers: [ + provideZonelessChangeDetection(), + provideRouter([]), + { provide: LOCALE_ID, useValue: 'en-US' }, + ], + }); + fixture = TestBed.createComponent(MaintenanceReportTable); + fixture.componentRef.setInput('currency', 'EUR'); + fixture.componentRef.setInput('rows', [row()]); + fixture.componentRef.setInput('totalItems', 1); + dossierRequests = []; + pageRequests = []; + pageSizeRequests = []; + fixture.componentInstance.dossiersRequested.subscribe((value) => dossierRequests.push(value)); + fixture.componentInstance.pageChanged.subscribe((value) => pageRequests.push(value)); + fixture.componentInstance.pageSizeChanged.subscribe((value) => pageSizeRequests.push(value)); + await fixture.whenStable(); + }); + + afterEach(() => TestBed.resetTestingModule()); + + it('preserves every digit of a large exact amount in both table and card presentations', async () => { + fixture.componentRef.setInput('rows', [row({ current: amount('9007199254740993.123456') })]); + await fixture.whenStable(); + + expect(text(cardMetric('Current realized cost').querySelector('dd'))).toBe( + '9,007,199,254,740,993.123456 EUR', + ); + expect(text(gridCells()[1])).toBe('9,007,199,254,740,993.123456 EUR'); + expect(root().textContent).not.toContain('9,007,199,254,740,992'); + }); + + it('keeps an incomplete total unknown while displaying its zero known subtotal and unknown count', async () => { + const incomplete: MaintenanceEconomicAmount = { + knownTotal: '0.000000', + complete: false, + contributionCount: 3, + unknownCount: 3, + }; + fixture.componentRef.setInput('rows', [row({ current: incomplete, frozen: amount() })]); + await fixture.whenStable(); + + const current = cardMetric('Current realized cost'); + expect(text(current.querySelector('dd'))).toBe('Unknown'); + expect(Array.from(current.querySelectorAll('dd:nth-of-type(2) > span'), text)).toEqual([ + 'Known subtotal', + '0.000000 EUR', + 'Unvalued contributions: 3', + ]); + expect(Array.from(gridCells()[1].querySelectorAll(':scope > div > span'), text)).toEqual([ + 'Unknown', + 'Known subtotal', + '0.000000 EUR', + 'Unvalued contributions: 3', + ]); + expect(text(cardMetric('Cost at closure').querySelector('dd'))).toBe('0.000000 EUR'); + expect(text(gridCells()[2])).toBe('0.000000 EUR'); + }); + + it('does not present a positive known subtotal as a completed realized total', async () => { + fixture.componentRef.setInput('rows', [ + row({ + current: { + total: '42.000000', + knownTotal: '42.000000', + complete: false, + contributionCount: 4, + unknownCount: 1, + }, + }), + ]); + await fixture.whenStable(); + + expect(text(cardMetric('Current realized cost').querySelector('dd'))).toBe('Unknown'); + expect(Array.from(gridCells()[1].querySelectorAll(':scope > div > span'), text)).toEqual([ + 'Unknown', + 'Known subtotal', + '42.000000 EUR', + 'Unvalued contributions: 1', + ]); + }); + + it('shows the forecast and budget separately without summing them into realized costs', () => { + const headers = Array.from(root().querySelectorAll('thead th'), text); + expect(headers).toEqual([ + 'Allocation', + 'Current realized cost', + 'Cost at closure', + 'Forecast cost', + 'Budget', + 'Realized minus forecast', + 'Source dossiers', + ]); + expect(text(gridCells()[1])).toBe('120.000000 EUR'); + expect(text(gridCells()[3])).toBe('100.000000 EUR'); + expect(text(gridCells()[4])).toBe('150.000000 EUR'); + expect(text(cardMetric('Forecast cost').querySelector('dd'))).toBe('100.000000 EUR'); + expect(text(cardMetric('Budget').querySelector('dd'))).toBe('150.000000 EUR'); + }); + + it.each([ + ['-0.000001', '−0.000001 EUR'], + ['20.000000', '20.000000 EUR'], + [null, 'Unknown'], + ])('renders the exact signed or unknown variance %s', async (variance, expected) => { + fixture.componentRef.setInput('rows', [row({ variance })]); + await fixture.whenStable(); + + expect(text(cardMetric('Realized minus forecast').querySelector('dd'))).toBe(expected); + expect(text(gridCells()[5])).toBe(expected); + }); + + it('names unallocated and missing historical identities explicitly without exposing raw ids', async () => { + const historical = row({ name: ' ', identityState: 'incomplete', allocationComplete: false }); + const unallocated = row({ + id: null, + name: null, + identityState: 'unallocated', + allocationComplete: false, + }); + fixture.componentRef.setInput('rows', [historical, unallocated]); + fixture.componentRef.setInput('showPagination', false); + await fixture.whenStable(); + + expect(Array.from(gridCells(0)[0].querySelectorAll(':scope > div > span'), text)).toEqual([ + 'Historical identity unavailable', + 'Identity is incomplete', + 'Allocation incomplete', + ]); + expect(Array.from(gridCells(1)[0].querySelectorAll(':scope > div > span'), text)).toEqual([ + 'Unallocated costs', + 'No allocation identity', + 'Allocation incomplete', + ]); + expect(Array.from(root().querySelectorAll('h3'), text)).toEqual([ + 'Historical identity unavailable', + 'Unallocated costs', + ]); + expect(root().textContent).not.toContain(historical.id); + expect(root().textContent).not.toContain(historical.interventionIds[0]); + expect(root().querySelector('app-collection-pagination')).toBeNull(); + }); + + it('keeps closure identity provenance and names both responsive presentations accessibly', async () => { + fixture.componentRef.setInput('caption', 'Recorded site allocations — January 2026'); + await fixture.whenStable(); + + expect(Array.from(gridCells()[0].querySelectorAll(':scope > div > span'), text)).toEqual([ + 'Extinguisher reception desk', + 'Identity recorded at closure', + ]); + expect(text(root().querySelector('caption'))).toBe('Recorded site allocations — January 2026'); + expect( + root().querySelector('[data-testid="maintenance-report-cards"]')?.getAttribute('aria-label'), + ).toBe('Recorded site allocations — January 2026'); + }); + + it.each(['maintenance-report-cards', 'maintenance-report-grid'])( + 'emits the complete selected allocation from the %s source action', + async (presentation) => { + const selected = row(); + fixture.componentRef.setInput('rows', [selected]); + await fixture.whenStable(); + + root().querySelector(`[data-testid="${presentation}"] button`)?.click(); + + expect(dossierRequests).toEqual([selected]); + expect(dossierRequests[0]).toBe(selected); + }, + ); + + it('disables source actions when there are no source dossiers', async () => { + fixture.componentRef.setInput('rows', [row({ interventionIds: [] })]); + await fixture.whenStable(); + + const actions = Array.from( + root().querySelectorAll( + '[data-testid="maintenance-report-cards"] button, [data-testid="maintenance-report-grid"] button', + ), + ); + expect(actions).toHaveLength(2); + expect(actions.map((action) => action.disabled)).toEqual([true, true]); + actions.forEach((action) => action.click()); + expect(dossierRequests).toEqual([]); + }); + + it('uses the full server destination count and forwards page and page-size intent', async () => { + fixture.componentRef.setInput('page', 2); + fixture.componentRef.setInput('itemsPerPage', 25); + fixture.componentRef.setInput('totalItems', 101); + await fixture.whenStable(); + + expect(text(root().querySelector('[data-testid="maintenance-report-row-count"]'))).toBe( + '1 of 101 rows shown', + ); + expect(text(root().querySelector('[data-testid="maintenance-report-page-indicator"]'))).toBe( + 'Page 2 of 5', + ); + root() + .querySelector('[data-testid="maintenance-report-page-last"]') + ?.click(); + fixture.debugElement.query(By.css('hlm-select')).triggerEventHandler('valueChange', 60); + + expect(pageRequests).toEqual([5]); + expect(pageSizeRequests).toEqual([60]); + expect(fixture.componentInstance.page()).toBe(2); + expect(fixture.componentInstance.itemsPerPage()).toBe(25); + expect(fixture.componentInstance.rows()).toHaveLength(1); + }); + + it('retains prior rows during a pending replacement read and blocks source actions', async () => { + fixture.componentRef.setInput('pending', true); + await fixture.whenStable(); + + expect( + root().querySelector('[data-testid="maintenance-report-table"]')?.getAttribute('aria-busy'), + ).toBe('true'); + expect(text(root().querySelector('[role="status"]'))).toBe('Reading economic allocations'); + expect(text(gridCells()[0])).toContain('Extinguisher reception desk'); + expect(text(cardMetric('Current realized cost').querySelector('dd'))).toBe('120.000000 EUR'); + const actions = Array.from( + root().querySelectorAll( + '[data-testid="maintenance-report-cards"] button, [data-testid="maintenance-report-grid"] button', + ), + ); + expect(actions.map((action) => action.disabled)).toEqual([true, true]); + actions.forEach((action) => action.click()); + expect(dossierRequests).toEqual([]); + expect(root().textContent).not.toContain('No costs allocated in this window'); + }); + + it('distinguishes an empty completed window from a pending initial read', async () => { + fixture.componentRef.setInput('rows', []); + await fixture.whenStable(); + expect(root().textContent).toContain('No costs allocated in this window'); + + fixture.componentRef.setInput('pending', true); + await fixture.whenStable(); + expect(root().textContent).not.toContain('No costs allocated in this window'); + expect(text(root().querySelector('[role="status"]'))).toBe('Reading economic allocations'); + }); +}); diff --git a/src/app/features/organization/features/maintenance-costs/utils/index.ts b/src/app/features/organization/features/maintenance-costs/utils/index.ts new file mode 100644 index 000000000..bf5763f5d --- /dev/null +++ b/src/app/features/organization/features/maintenance-costs/utils/index.ts @@ -0,0 +1,4 @@ +export { + formatMaintenanceAmount, + isMaintenanceAmount, +} from './maintenance-amount/maintenance-amount.utils'; diff --git a/src/app/features/organization/features/maintenance-costs/utils/maintenance-amount/maintenance-amount.utils.ts b/src/app/features/organization/features/maintenance-costs/utils/maintenance-amount/maintenance-amount.utils.ts new file mode 100644 index 000000000..a02701211 --- /dev/null +++ b/src/app/features/organization/features/maintenance-costs/utils/maintenance-amount/maintenance-amount.utils.ts @@ -0,0 +1,47 @@ +/** + * Function formatMaintenanceAmount + * + * @description + * Formats an exact server amount with integer grouping and its significant fraction, without + * floating-point conversion. + * + * @param {string | null | undefined} amount - Contract input value. + * @param {string} currency - Contract input value. + * @param {string} locale - Contract input value. + * + * @returns {string} Validated financial contract result. + */ +export function formatMaintenanceAmount( + amount: string | null | undefined, + currency: string, + locale: string, +): string { + if (amount === null || amount === undefined) + return $localize`:@@maintenanceCost.amount.unknown:Unknown`; + if (!/^-?(?:0|[1-9]\d*)(?:\.\d{1,6})?$/.test(amount)) + return $localize`:@@maintenanceCost.amount.unknown:Unknown`; + const negative = amount.startsWith('-'); + const [whole = '0', fraction = ''] = amount.replace(/^-/, '').split('.'); + const integer = new Intl.NumberFormat(locale).format(BigInt(whole)); + const parts = new Intl.NumberFormat(locale, { minimumFractionDigits: 1 }).formatToParts(1n); + const separator = parts.find((part) => part.type === 'decimal')?.value ?? '.'; + const significant = fraction.padEnd(6, '0'); + return `${negative ? '−' : ''}${integer}${separator}${significant} ${currency}`; +} + +/** + * Function isMaintenanceAmount + * + * @description + * Validates bounded exact decimal input; monetary values never enter number arithmetic. + * + * @param {string} value - Exact field text to validate. + * @param {unknown} signed - Contract input value. + * + * @returns {boolean} Validated financial contract result. + */ +export function isMaintenanceAmount(value: string, signed = false): boolean { + return ( + signed ? /^-?(?:0|[1-9]\d{0,17})(?:\.\d{1,6})?$/ : /^(?:0|[1-9]\d{0,17})(?:\.\d{1,6})?$/ + ).test(value.trim()); +} diff --git a/src/app/features/organization/features/maintenance-costs/utils/maintenance-amount/testing/maintenance-amount.utils.spec.ts b/src/app/features/organization/features/maintenance-costs/utils/maintenance-amount/testing/maintenance-amount.utils.spec.ts new file mode 100644 index 000000000..93fdb903e --- /dev/null +++ b/src/app/features/organization/features/maintenance-costs/utils/maintenance-amount/testing/maintenance-amount.utils.spec.ts @@ -0,0 +1,31 @@ +import { formatMaintenanceAmount, isMaintenanceAmount } from '../maintenance-amount.utils'; + +describe('exact maintenance amounts', () => { + it('preserves integers beyond floating precision and all six decimal places', () => { + expect(formatMaintenanceAmount('9007199254740993.123456', 'EUR', 'en-US')).toBe( + '9,007,199,254,740,993.123456 EUR', + ); + expect(formatMaintenanceAmount('-0.000001', 'USD', 'en-US')).toBe('−0.000001 USD'); + }); + it('distinguishes explicit zero from unknown and retains its six places', () => { + expect(formatMaintenanceAmount('0', 'EUR', 'en-US')).toBe('0.000000 EUR'); + expect(formatMaintenanceAmount(null, 'EUR', 'en-US')).toBe('Unknown'); + expect(formatMaintenanceAmount(undefined, 'EUR', 'en-US')).toBe('Unknown'); + expect(formatMaintenanceAmount('1e9', 'EUR', 'en-US')).toBe('Unknown'); + }); + it('uses the locale separators without converting the decimal to a number', () => { + expect(formatMaintenanceAmount('1234.1', 'EUR', 'fr-FR')).toBe('1\u202f234,100000 EUR'); + expect(formatMaintenanceAmount('1234.1', 'EUR', 'es-ES')).toBe('1234,100000 EUR'); + }); + it.each(['1e2', '1.1234567', '1,25', '01', '-1', '1000000000000000000', ''])( + 'rejects an invalid unsigned declaration %s', + (value: string) => { + expect(isMaintenanceAmount(value)).toBe(false); + }, + ); + it('permits a signed exact delta only when the caller explicitly allows it', () => { + expect(isMaintenanceAmount('-1.000001')).toBe(false); + expect(isMaintenanceAmount('-1.000001', true)).toBe(true); + expect(isMaintenanceAmount(' 9007199254740993.123456 ')).toBe(true); + }); +}); diff --git a/src/app/features/organization/features/maintenance-exports/FEATURE.md b/src/app/features/organization/features/maintenance-exports/FEATURE.md new file mode 100644 index 000000000..8f119eb4f --- /dev/null +++ b/src/app/features/organization/features/maintenance-exports/FEATURE.md @@ -0,0 +1,32 @@ +# Maintenance exports + +Maintenance exports owns immutable, versioned CSV/JSON prestation archives and their external +resource reference mappings. Export generation and an operator-confirmed external import are +distinct server states. Commercial quotations, invoices, taxes and payments remain in the ERP. + +The route tree is `/organizations/:organizationId/maintenance-exports`, publicly composed through +`MAINTENANCE_EXPORT_ROUTES`. Published dossier choices are server-paginated and retain their +publication-time customer/site identities. A missing historical snapshot blocks selection explicitly. +External references are picked from readable owner directories, never entered as arbitrary UUIDs. +Customer choices explicitly browse active or archived server pages under the same Customer read grant. +A historical archived customer remains mappable without restoring it; changing directory pages or status +retains the chosen stable identity and reviewed mapping draft, with no customer contacts projected. + +The route-scoped SignalStore owns Hydra transport, named request states, optimistic revisions, +exact operation replay and authenticated organization/session fences. Reads are browser-only; +no private archive, reference or financial response enters TransferState or SSR HTML. +Accepted writes survive navigation while late results cannot populate another session or scope. +Uncertain operations retain their exact UUID, revision and body until recovery. Confirmed conflicts +keep the draft and require explicit source review before a new command is accepted. + +Forms own Signal Forms state and emit intents only. Native sheets stay open during writes and +failures. Downloads return retained server bytes; the browser never rebuilds a file. Adjustments +append a linked archive and preserve every predecessor. Internal costs additionally require the +independent financial read permission for creation, detail and download. + +Approved dependencies: organization access/models/ports and services/browser-download; +auth session port; Customer, Facility and Equipment public data-access/models for scoped +external-reference directory choices. Owner directory reads respect their separate permissions. + +Public concern APIs are models, data-access MaintenanceExportService and state MaintenanceExportStore. +No root barrel, portal, external send, automatic import claim or commercial invoicing is introduced. diff --git a/src/app/features/organization/features/maintenance-exports/data-access/index.ts b/src/app/features/organization/features/maintenance-exports/data-access/index.ts new file mode 100644 index 000000000..da3a5ea0f --- /dev/null +++ b/src/app/features/organization/features/maintenance-exports/data-access/index.ts @@ -0,0 +1 @@ +export { MaintenanceExportService } from './services/maintenance-export/maintenance-export.service'; diff --git a/src/app/features/organization/features/maintenance-exports/data-access/services/maintenance-export/maintenance-export.service.ts b/src/app/features/organization/features/maintenance-exports/data-access/services/maintenance-export/maintenance-export.service.ts new file mode 100644 index 000000000..1091a9928 --- /dev/null +++ b/src/app/features/organization/features/maintenance-exports/data-access/services/maintenance-export/maintenance-export.service.ts @@ -0,0 +1,268 @@ +import { Service } from '@angular/core'; +import type { Observable } from 'rxjs'; +import { HydraApiService } from '@core/api'; +import type { HydraCollection, RequestOptions } from '@core/api/models'; +import type { + MaintenanceExportOutput, + MaintenanceExportSourceOutput, + MaintenanceExportReferenceOutput, + MaintenanceExportResourceType, + CreateMaintenanceExportInput, + AdjustMaintenanceExportInput, + ConfirmMaintenanceExportInput, + WriteMaintenanceExportReferenceInput, +} from '@features/organization/features/maintenance-exports/models'; + +/** + * Class MaintenanceExportService + * @class MaintenanceExportService + * + * @description + * Scoped archive transport preserves retained bytes, optimistic revisions and operation receipts. + */ +@Service() +export class MaintenanceExportService extends HydraApiService { + //#region Methods + /** + * Method list + * @method list + * + * @description + * Reads one server page of authorized archive metadata. + * + * @access public + * @since unreleased + * + * @param {string} organizationId - Owning organization. + * @param {RequestOptions | undefined} options - Bounded server query. + * + * @returns {Observable>} Authorized metadata page. + */ + public list( + organizationId: string, + options?: RequestOptions, + ): Observable> { + return this.getCollection( + `/api/organizations/${organizationId}/maintenance-exports`, + options, + ); + } + + /** + * Method read + * @method read + * + * @description + * Reads the selected retained archive, including its immutable correction links. + * + * @access public + * @since unreleased + * + * @param {string} organizationId - Owning organization. + * @param {string} exportId - Retained archive identity. + * + * @returns {Observable} Scoped archive metadata. + */ + public read(organizationId: string, exportId: string): Observable { + return this.getOne( + `/api/organizations/${organizationId}/maintenance-exports/${exportId}`, + ); + } + + /** + * Method listSources + * @method listSources + * + * @description + * Lists published dossier readiness without reconstructing it from current live data. + * + * @access public + * @since unreleased + * + * @param {string} organizationId - Owning organization. + * @param {RequestOptions | undefined} options - Server page and search. + * + * @returns {Observable>} Published selection page. + */ + public listSources( + organizationId: string, + options?: RequestOptions, + ): Observable> { + return this.getCollection( + `/api/organizations/${organizationId}/maintenance-export-sources`, + options, + ); + } + + /** + * Method create + * @method create + * + * @description + * Creates one retained archive or recovers the same operation receipt on replay. + * + * @access public + * @since unreleased + * + * @param {string} organizationId - Owning organization. + * @param {CreateMaintenanceExportInput} input - Stable bounded creation command. + * + * @returns {Observable} Original archive receipt. + */ + public create( + organizationId: string, + input: CreateMaintenanceExportInput, + ): Observable { + return this.post( + `/api/organizations/${organizationId}/maintenance-exports`, + input, + ); + } + + /** + * Method adjust + * @method adjust + * + * @description + * Appends a motivated adjustment using the displayed predecessor revision. + * + * @access public + * @since unreleased + * + * @param {string} organizationId - Owning organization. + * @param {string} exportId - Retained predecessor identity. + * @param {AdjustMaintenanceExportInput} input - Stable reason and receipt identity. + * @param {number} revision - Displayed predecessor revision. + * + * @returns {Observable} New linked immutable archive. + */ + public adjust( + organizationId: string, + exportId: string, + input: AdjustMaintenanceExportInput, + revision: number, + ): Observable { + return this.post( + `/api/organizations/${organizationId}/maintenance-exports/${exportId}/adjustments`, + input, + { headers: { 'If-Match': `"revision-${revision}"` } }, + ); + } + + /** + * Method confirm + * @method confirm + * + * @description + * Records an explicitly confirmed external import independently of generation. + * + * @access public + * @since unreleased + * + * @param {string} organizationId - Owning organization. + * @param {string} exportId - Imported retained archive. + * @param {ConfirmMaintenanceExportInput} input - Stable external acknowledgement. + * @param {number} revision - Displayed administrative revision. + * + * @returns {Observable} Confirmed import metadata. + */ + public confirm( + organizationId: string, + exportId: string, + input: ConfirmMaintenanceExportInput, + revision: number, + ): Observable { + return this.post( + `/api/organizations/${organizationId}/maintenance-exports/${exportId}/confirm`, + input, + { headers: { 'If-Match': `"revision-${revision}"` } }, + ); + } + + /** + * Method download + * @method download + * + * @description + * Returns the persisted file bytes without client-side regeneration or conversion. + * + * @access public + * @since unreleased + * + * @param {string} organizationId - Owning organization. + * @param {string} exportId - Retained archive identity. + * @param {'json' | 'csv'} format - Persisted format. + * + * @returns {Observable} Exact retained byte stream. + */ + public download( + organizationId: string, + exportId: string, + format: 'json' | 'csv', + ): Observable { + return this.http.get( + this.buildUrl( + `/api/organizations/${organizationId}/maintenance-exports/${exportId}/files/${format}`, + ), + { responseType: 'blob', withCredentials: true }, + ); + } + + /** + * Method listReferences + * @method listReferences + * + * @description + * Lists current mapping revisions; generated archives keep their own frozen references. + * + * @access public + * @since unreleased + * + * @param {string} organizationId - Owning organization. + * @param {RequestOptions | undefined} options - Server page and mapping scope. + * + * @returns {Observable>} Current mapping page. + */ + public listReferences( + organizationId: string, + options?: RequestOptions, + ): Observable> { + return this.getCollection( + `/api/organizations/${organizationId}/maintenance-export-references`, + options, + ); + } + + /** + * Method writeReference + * @method writeReference + * + * @description + * Writes one mapping at the reviewed revision, using revision zero only for a confirmed absence. + * + * @access public + * @since unreleased + * + * @param {string} organizationId - Owning organization. + * @param {MaintenanceExportResourceType} type - Supported owner type. + * @param {string} resourceId - Readable scoped selection identity. + * @param {WriteMaintenanceExportReferenceInput} input - Exact stable mapping command. + * @param {number} revision - Reviewed current revision or zero for absence. + * + * @returns {Observable} Server-confirmed current mapping. + */ + public writeReference( + organizationId: string, + type: MaintenanceExportResourceType, + resourceId: string, + input: WriteMaintenanceExportReferenceInput, + revision: number, + ): Observable { + return this.patch( + `/api/organizations/${organizationId}/maintenance-export-references/${type}/${resourceId}`, + input, + { headers: { 'If-Match': `"revision-${revision}"` } }, + ); + } + //#endregion +} diff --git a/src/app/features/organization/features/maintenance-exports/data-access/services/maintenance-export/testing/maintenance-export.service.spec.ts b/src/app/features/organization/features/maintenance-exports/data-access/services/maintenance-export/testing/maintenance-export.service.spec.ts new file mode 100644 index 000000000..afeec113f --- /dev/null +++ b/src/app/features/organization/features/maintenance-exports/data-access/services/maintenance-export/testing/maintenance-export.service.spec.ts @@ -0,0 +1,104 @@ +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { ENV_CONFIG } from '@core/config/environment/env.token'; +import { maintenanceExportFixture } from '@features/organization/features/maintenance-exports/models/export/testing/maintenance-export.fixture'; +import { MaintenanceExportService } from '../maintenance-export.service'; + +describe('MaintenanceExportService contract', () => { + let api: MaintenanceExportService, http: HttpTestingController; + const base = 'https://api.test/api/organizations/org'; + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + MaintenanceExportService, + { provide: ENV_CONFIG, useValue: { apiUrl: 'https://api.test' } }, + ], + }); + api = TestBed.inject(MaintenanceExportService); + http = TestBed.inject(HttpTestingController); + }); + afterEach(() => http.verify()); + it('retains a bounded dossier selection and exact operation identity', () => { + const input = { + clientOperationId: 'stable', + interventionIds: ['dossier-1'], + system: 'ERP', + includeInternalCosts: false, + }; + api.create('org', input).subscribe(); + const request = http.expectOne(base + '/maintenance-exports'); + expect(request.request.method).toBe('POST'); + expect(request.request.body).toEqual(input); + request.flush(maintenanceExportFixture()); + }); + it.each(['adjust', 'confirm'] as const)('uses the displayed quoted revision for %s', (kind) => { + if (kind === 'adjust') + api + .adjust( + 'org', + 'export-1', + { clientOperationId: 'stable-adjust', reason: 'Validated correction' }, + 4, + ) + .subscribe(); + else + api + .confirm( + 'org', + 'export-1', + { clientOperationId: 'stable-confirm', externalImportReference: 'IMPORT-25' }, + 4, + ) + .subscribe(); + const request = http.expectOne( + base + '/maintenance-exports/export-1/' + (kind === 'adjust' ? 'adjustments' : 'confirm'), + ); + expect(request.request.headers.get('If-Match')).toBe('"revision-4"'); + expect(request.request.method).toBe('POST'); + request.flush(maintenanceExportFixture()); + }); + it('downloads retained CSV bytes without rebuilding rows or JSON conversion', () => { + let downloaded: Blob | undefined; + const bytes = new Blob(['id;amount\r\noriginal;0.000001\r\n'], { type: 'text/csv' }); + api.download('org', 'export-1', 'csv').subscribe((blob) => { + downloaded = blob; + }); + const request = http.expectOne(base + '/maintenance-exports/export-1/files/csv'); + expect(request.request.responseType).toBe('blob'); + expect(request.request.withCredentials).toBe(true); + request.flush(bytes); + expect(downloaded).toBe(bytes); + }); + it('queries the exact mapping tuple and writes optimistic revision zero only for a reviewed absence', () => { + api + .listReferences('org', { + page: 1, + itemsPerPage: 30, + params: { resourceType: 'equipment', resourceId: 'equipment-1', system: 'ERP' }, + }) + .subscribe(); + const read = http.expectOne( + (request) => request.url === base + '/maintenance-export-references', + ); + expect(read.request.params.get('resourceId')).toBe('equipment-1'); + expect(read.request.params.get('system')).toBe('ERP'); + read.flush({ member: [], totalItems: 0 }); + const input = { clientOperationId: 'stable-map', system: 'ERP', reference: 'ERP-E-1' }; + api.writeReference('org', 'equipment', 'equipment-1', input, 0).subscribe(); + const write = http.expectOne(base + '/maintenance-export-references/equipment/equipment-1'); + expect(write.request.headers.get('If-Match')).toBe('"revision-0"'); + expect(write.request.body).toEqual(input); + write.flush({ id: 'map', ...input, revision: 1 }); + }); + it('sends published selection search and page to the server', () => { + api.listSources('org', { page: 3, itemsPerPage: 30, search: 'Park control' }).subscribe(); + const request = http.expectOne((item) => item.url === base + '/maintenance-export-sources'); + expect(request.request.params.get('page')).toBe('3'); + expect(request.request.params.get('itemsPerPage')).toBe('30'); + expect(request.request.params.get('search')).toBe('Park control'); + request.flush({ member: [], totalItems: 0 }); + }); +}); diff --git a/src/app/features/organization/features/maintenance-exports/maintenance-exports.routes.ts b/src/app/features/organization/features/maintenance-exports/maintenance-exports.routes.ts new file mode 100644 index 000000000..89c816ba1 --- /dev/null +++ b/src/app/features/organization/features/maintenance-exports/maintenance-exports.routes.ts @@ -0,0 +1,32 @@ +import type { Routes } from '@angular/router'; +import { DASHBOARD_MOBILE_NAVIGATION_ROOT_DATA_KEY, type DashboardRouteData } from '@core/routing'; +import { organizationPermissionGuard } from '@features/organization/http/guards'; +import { ORGANIZATION_PERMISSION } from '@features/organization/models'; +import { unsavedChangesGuard } from '@shared/unsaved-changes'; + +/** + * Constant MAINTENANCE_EXPORT_ROUTES + * + * @description + * Permission-gated archive workspace keeps its private reads browser-only. + */ +export const MAINTENANCE_EXPORT_ROUTES: Routes = [ + { + path: '', + canDeactivate: [unsavedChangesGuard], + canActivate: [ + organizationPermissionGuard({ + permissions: [ORGANIZATION_PERMISSION.MAINTENANCE_EXPORTS_READ], + }), + ], + loadComponent: () => + import('./ui/pages/maintenance-exports-page/maintenance-exports-page.component').then( + (module) => module.MaintenanceExportsPage, + ), + title: $localize`:@@route.maintenanceExports:Maintenance exports`, + data: { + breadcrumb: $localize`:@@route.maintenanceExports:Maintenance exports`, + [DASHBOARD_MOBILE_NAVIGATION_ROOT_DATA_KEY]: true, + } satisfies DashboardRouteData, + }, +]; diff --git a/src/app/features/organization/features/maintenance-exports/models/export/maintenance-export-input.interface.ts b/src/app/features/organization/features/maintenance-exports/models/export/maintenance-export-input.interface.ts new file mode 100644 index 000000000..ddebd3ff0 --- /dev/null +++ b/src/app/features/organization/features/maintenance-exports/models/export/maintenance-export-input.interface.ts @@ -0,0 +1,145 @@ +/** + * Interface CreateMaintenanceExportInput + * @interface CreateMaintenanceExportInput + * + * @description + * Accepted creation keeps a stable operation identity and exact bounded dossier selection. + */ +export interface CreateMaintenanceExportInput { + /** + * Property clientOperationId + * + * @description + * Stable operation receipt identity. + * + * @type {string} + */ + readonly clientOperationId: string; + + /** + * Property interventionIds + * + * @description + * One to one hundred selected published dossiers. + * + * @type {readonly string[]} + */ + readonly interventionIds: readonly string[]; + + /** + * Property system + * + * @description + * External target code of at most forty characters. + * + * @type {string} + */ + readonly system: string; + + /** + * Property includeInternalCosts + * + * @description + * Private internal costs explicitly requested with dedicated permission. + * + * @type {boolean} + */ + readonly includeInternalCosts: boolean; +} + +/** + * Interface AdjustMaintenanceExportInput + * @interface AdjustMaintenanceExportInput + * + * @description + * Motivated correction appends a linked archive without changing the original bytes. + */ +export interface AdjustMaintenanceExportInput { + /** + * Property clientOperationId + * + * @description + * Stable adjustment receipt identity. + * + * @type {string} + */ + readonly clientOperationId: string; + + /** + * Property reason + * + * @description + * Operator reason for the appended correction. + * + * @type {string} + */ + readonly reason: string; +} + +/** + * Interface ConfirmMaintenanceExportInput + * @interface ConfirmMaintenanceExportInput + * + * @description + * External import is acknowledged only after the operator supplies its actual reference. + */ +export interface ConfirmMaintenanceExportInput { + /** + * Property clientOperationId + * + * @description + * Stable acknowledgement receipt identity. + * + * @type {string} + */ + readonly clientOperationId: string; + + /** + * Property externalImportReference + * + * @description + * Actual external import reference. + * + * @type {string} + */ + readonly externalImportReference: string; +} + +/** + * Interface WriteMaintenanceExportReferenceInput + * @interface WriteMaintenanceExportReferenceInput + * + * @description + * Exact external mapping command retains its operation identity across transport retries. + */ +export interface WriteMaintenanceExportReferenceInput { + /** + * Property clientOperationId + * + * @description + * Stable mapping write identity. + * + * @type {string} + */ + readonly clientOperationId: string; + + /** + * Property system + * + * @description + * Target external system code. + * + * @type {string} + */ + readonly system: string; + + /** + * Property reference + * + * @description + * External resource identifier. + * + * @type {string} + */ + readonly reference: string; +} diff --git a/src/app/features/organization/features/maintenance-exports/models/export/maintenance-export-output.interface.ts b/src/app/features/organization/features/maintenance-exports/models/export/maintenance-export-output.interface.ts new file mode 100644 index 000000000..48b13bf8d --- /dev/null +++ b/src/app/features/organization/features/maintenance-exports/models/export/maintenance-export-output.interface.ts @@ -0,0 +1,299 @@ +import type { HydraItem } from '@core/api/models'; + +/** + * Interface MaintenanceExportFile + * @interface MaintenanceExportFile + * + * @description + * Integrity metadata describes retained bytes rather than a newly computed download. + */ +export interface MaintenanceExportFile { + /** + * Property mediaType + * + * @description + * Exact server media type. + * + * @type {string} + */ + readonly mediaType: string; + + /** + * Property sha256 + * + * @description + * Checksum of the persisted file. + * + * @type {string} + */ + readonly sha256: string; + + /** + * Property size + * + * @description + * Persisted byte count. + * + * @type {number} + */ + readonly size: number; +} + +/** + * Interface MaintenanceExportConfirmation + * @interface MaintenanceExportConfirmation + * + * @description + * Manual acknowledgement records an actual external import independently from generation. + */ +export interface MaintenanceExportConfirmation { + /** + * Property clientOperationId + * + * @description + * Stable confirmation identity. + * + * @type {string} + */ + readonly clientOperationId: string; + + /** + * Property externalImportReference + * + * @description + * External import acknowledgement supplied by the operator. + * + * @type {string} + */ + readonly externalImportReference: string; + + /** + * Property confirmedAt + * + * @description + * Server acknowledgement instant. + * + * @type {string} + */ + readonly confirmedAt: string; + + /** + * Property actorId + * + * @description + * Member who confirmed the import. + * + * @type {string} + */ + readonly actorId: string; +} + +/** + * Interface MaintenanceExportOutput + * @interface MaintenanceExportOutput + * + * @description + * Immutable versioned export archive; adjustments retain the original archive and publication + * identity. + */ +export interface MaintenanceExportOutput extends HydraItem { + /** + * Property id + * + * @description + * Stable export identity. + * + * @type {string} + */ + readonly id: string; + + /** + * Property organizationId + * + * @description + * Owning organization boundary. + * + * @type {string} + */ + readonly organizationId: string; + + /** + * Property kind + * + * @description + * Original export or linked correction. + * + * @type {'initial' | 'adjustment'} + */ + readonly kind: 'initial' | 'adjustment'; + + /** + * Property schemaVersion + * + * @description + * Version of the persisted row schema. + * + * @type {1} + */ + readonly schemaVersion: 1; + + /** + * Property system + * + * @description + * Target external system code. + * + * @type {string} + */ + readonly system: string; + + /** + * Property includeInternalCosts + * + * @description + * Whether this archive requires separate financial read permission. + * + * @type {boolean} + */ + readonly includeInternalCosts: boolean; + + /** + * Property sourceInterventionIds + * + * @description + * Stable published dossiers included in this export. + * + * @type {readonly string[]} + */ + readonly sourceInterventionIds: readonly string[]; + + /** + * Property originalExportId + * + * @description + * Root export in a correction chain. + * + * @type {string | null} + */ + readonly originalExportId?: string | null; + + /** + * Property adjustmentOf + * + * @description + * Direct predecessor corrected by this archive. + * + * @type {string | null} + */ + readonly adjustmentOf?: string | null; + + /** + * Property reason + * + * @description + * Immutable operator reason retained for the linked adjustment. + * + * @type {string | null} + */ + readonly reason?: string | null; + + /** + * Property createdAt + * + * @description + * Archive creation instant. + * + * @type {string} + */ + readonly createdAt: string; + + /** + * Property actorId + * + * @description + * Author of the immutable archive. + * + * @type {string} + */ + readonly actorId: string; + + /** + * Property revision + * + * @description + * Displayed optimistic revision for administrative acknowledgements. + * + * @type {number} + */ + readonly revision: number; + + /** + * Property state + * + * @description + * Generation and confirmed external import remain distinct. + * + * @type {'generated' | 'import_confirmed'} + */ + readonly state: 'generated' | 'import_confirmed'; + + /** + * Property confirmation + * + * @description + * Explicit import acknowledgement if one exists. + * + * @type {MaintenanceExportConfirmation | null} + */ + readonly confirmation?: MaintenanceExportConfirmation | null; + + /** + * Property rowCount + * + * @description + * Number of persisted prestation rows. + * + * @type {number} + */ + readonly rowCount: number; + + /** + * Property files + * + * @description + * Both retained formats share the same archive identity. + * + * @type {{ readonly json: MaintenanceExportFile; readonly csv: MaintenanceExportFile }} + */ + readonly files: { readonly json: MaintenanceExportFile; readonly csv: MaintenanceExportFile }; + + /** + * Property replayed + * + * @description + * Whether the server recovered the original operation receipt. + * + * @type {boolean} + */ + readonly replayed: boolean; + + /** + * Property costsComplete + * + * @description + * Unknown financial values remain explicitly incomplete when costs are included. + * + * @type {boolean | null} + */ + readonly costsComplete?: boolean | null; + + /** + * Property incompleteCostCount + * + * @description + * Number of retained prestations with explicitly incomplete costs in a private archive. + * + * @type {number | null} + */ + readonly incompleteCostCount?: number | null; +} diff --git a/src/app/features/organization/features/maintenance-exports/models/export/testing/maintenance-export.fixture.ts b/src/app/features/organization/features/maintenance-exports/models/export/testing/maintenance-export.fixture.ts new file mode 100644 index 000000000..87c67b274 --- /dev/null +++ b/src/app/features/organization/features/maintenance-exports/models/export/testing/maintenance-export.fixture.ts @@ -0,0 +1,45 @@ +import type { MaintenanceExportOutput } from '../maintenance-export-output.interface'; + +/** + * Function maintenanceExportFixture + * + * @description + * Builds server-authoritative archive metadata for behavior tests; retained file bytes are never + * reconstructed from this fixture. + * + * @access public + * @since unreleased + * + * @param {Partial} changes - Explicit scenario metadata overrides. + * + * @returns {MaintenanceExportOutput} Typed scoped archive metadata with immutable file manifests. + */ +export const maintenanceExportFixture = ( + changes: Partial = {}, +): MaintenanceExportOutput => ({ + '@id': '/api/organizations/org/maintenance-exports/export-1', + '@type': 'MaintenanceExport', + id: 'export-1', + organizationId: 'org', + kind: 'initial', + schemaVersion: 1, + system: 'ERP', + includeInternalCosts: false, + sourceInterventionIds: ['dossier-1'], + originalExportId: null, + adjustmentOf: null, + createdAt: '2026-10-07T12:00:00Z', + actorId: 'member', + revision: 1, + state: 'generated', + confirmation: null, + rowCount: 1, + files: { + json: { mediaType: 'application/json', sha256: 'original-json-hash', size: 120 }, + csv: { mediaType: 'text/csv', sha256: 'original-csv-hash', size: 60 }, + }, + replayed: false, + costsComplete: null, + incompleteCostCount: null, + ...changes, +}); diff --git a/src/app/features/organization/features/maintenance-exports/models/index.ts b/src/app/features/organization/features/maintenance-exports/models/index.ts new file mode 100644 index 000000000..244455d4d --- /dev/null +++ b/src/app/features/organization/features/maintenance-exports/models/index.ts @@ -0,0 +1,18 @@ +export type { + MaintenanceExportOutput, + MaintenanceExportFile, + MaintenanceExportConfirmation, +} from './export/maintenance-export-output.interface'; +export type { + CreateMaintenanceExportInput, + AdjustMaintenanceExportInput, + ConfirmMaintenanceExportInput, + WriteMaintenanceExportReferenceInput, +} from './export/maintenance-export-input.interface'; +export type { MaintenanceExportSourceOutput } from './source/maintenance-export-source-output.interface'; +export type { + MaintenanceExportReferenceOutput, + MaintenanceExportReferenceTarget, + MaintenanceExportReferencePage, + MaintenanceExportResourceType, +} from './reference/maintenance-export-reference-output.interface'; diff --git a/src/app/features/organization/features/maintenance-exports/models/reference/maintenance-export-reference-output.interface.ts b/src/app/features/organization/features/maintenance-exports/models/reference/maintenance-export-reference-output.interface.ts new file mode 100644 index 000000000..a8ed19aa6 --- /dev/null +++ b/src/app/features/organization/features/maintenance-exports/models/reference/maintenance-export-reference-output.interface.ts @@ -0,0 +1,148 @@ +import type { HydraItem } from '@core/api/models'; + +/** + * Type MaintenanceExportResourceType + * + * @description + * Owner identifiers supported by the versioned external mapping. + * + * @type {MaintenanceExportResourceType} + */ +export type MaintenanceExportResourceType = 'customer' | 'site' | 'equipment'; + +/** + * Interface MaintenanceExportReferenceOutput + * @interface MaintenanceExportReferenceOutput + * + * @description + * Optimistic external mapping changes affect future exports only. + */ +export interface MaintenanceExportReferenceOutput extends HydraItem { + /** + * Property id + * + * @description + * Stable mapping identity. + * + * @type {string} + */ + readonly id: string; + + /** + * Property resourceType + * + * @description + * Owner type of the mapped resource. + * + * @type {MaintenanceExportResourceType} + */ + readonly resourceType: MaintenanceExportResourceType; + + /** + * Property resourceId + * + * @description + * Organization-scoped owner identifier. + * + * @type {string} + */ + readonly resourceId: string; + + /** + * Property system + * + * @description + * External system code. + * + * @type {string} + */ + readonly system: string; + + /** + * Property reference + * + * @description + * Readable external identifier. + * + * @type {string} + */ + readonly reference: string; + + /** + * Property revision + * + * @description + * Displayed mapping revision; absence starts at zero. + * + * @type {number} + */ + readonly revision: number; + + /** + * Property updatedAt + * + * @description + * Latest mapping write instant. + * + * @type {string} + */ + readonly updatedAt: string; +} + +/** + * Interface MaintenanceExportReferenceTarget + * @interface MaintenanceExportReferenceTarget + * + * @description + * Readable authorized mapping choice; no contacts or arbitrary UUID input are exposed. + */ +export interface MaintenanceExportReferenceTarget { + /** + * Property id + * + * @description + * Scoped resource identity. + * + * @type {string} + */ + readonly id: string; + + /** + * Property label + * + * @description + * Owner-provided human readable identity. + * + * @type {string} + */ + readonly label: string; +} + +/** + * Interface MaintenanceExportReferencePage + * @interface MaintenanceExportReferencePage + * + * @description + * Minimal readable directory projection retains the authoritative server count without contacts. + */ +export interface MaintenanceExportReferencePage { + /** + * Property member + * + * @description + * Readable owner choices for the current server page. + * + * @type {readonly MaintenanceExportReferenceTarget[]} + */ + readonly member: readonly MaintenanceExportReferenceTarget[]; + + /** + * Property totalItems + * + * @description + * Authoritative owner count for this exact scope. + * + * @type {number} + */ + readonly totalItems: number; +} diff --git a/src/app/features/organization/features/maintenance-exports/models/source/maintenance-export-source-output.interface.ts b/src/app/features/organization/features/maintenance-exports/models/source/maintenance-export-source-output.interface.ts new file mode 100644 index 000000000..d15e8627a --- /dev/null +++ b/src/app/features/organization/features/maintenance-exports/models/source/maintenance-export-source-output.interface.ts @@ -0,0 +1,130 @@ +import type { HydraItem } from '@core/api/models'; + +/** + * Interface MaintenanceExportSourceOutput + * @interface MaintenanceExportSourceOutput + * + * @description + * Published dossier selector uses server readiness, historical scope and bounded pagination. + */ +export interface MaintenanceExportSourceOutput extends HydraItem { + /** + * Property id + * + * @description + * Published intervention identity. + * + * @type {string} + */ + readonly id: string; + + /** + * Property number + * + * @description + * Readable dossier number. + * + * @type {string} + */ + readonly number: number; + + /** + * Property name + * + * @description + * Readable dossier title. + * + * @type {string} + */ + readonly name: string; + + /** + * Property type + * + * @description + * Server intervention type. + * + * @type {string} + */ + readonly type: string; + + /** + * Property publishedAt + * + * @description + * Actual publication instant. + * + * @type {string | null} + */ + readonly publishedAt: string | null; + + /** + * Property publicationId + * + * @description + * Immutable publication identity. + * + * @type {string | null} + */ + readonly publicationId?: string | null; + + /** + * Property site + * + * @description + * Site identity retained by the dossier. + * + * @type {{ readonly id: string; readonly name: string } | null} + */ + readonly site?: { readonly id: string; readonly name: string } | null; + + /** + * Property customer + * + * @description + * Minimal internal customer identity without contacts. + * + * @type {{ readonly id: string; readonly name: string } | null} + */ + readonly customer?: { readonly id: string; readonly name: string } | null; + + /** + * Property snapshotState + * + * @description + * Server classification of retained publication evidence. + * + * @type {string} + */ + readonly snapshotState: string; + + /** + * Property identityComplete + * + * @description + * Whether the retained identity is complete. + * + * @type {boolean} + */ + readonly identityComplete: boolean; + + /** + * Property ready + * + * @description + * Server decision whether this dossier may be exported. + * + * @type {boolean} + */ + readonly ready: boolean; + + /** + * Property blockedReason + * + * @description + * Explicit blocker for legacy publications without a retained snapshot. + * + * @type {'snapshot_missing' | 'no_validated_work' | null} + */ + readonly blockedReason?: 'snapshot_missing' | 'no_validated_work' | null; +} diff --git a/src/app/features/organization/features/maintenance-exports/state/index.ts b/src/app/features/organization/features/maintenance-exports/state/index.ts new file mode 100644 index 000000000..1d2d6d232 --- /dev/null +++ b/src/app/features/organization/features/maintenance-exports/state/index.ts @@ -0,0 +1,2 @@ +export { MaintenanceExportStore, maintenanceExportStoreEvents } from './maintenance-export'; +export type { MaintenanceExportStoreType } from './maintenance-export'; diff --git a/src/app/features/organization/features/maintenance-exports/state/maintenance-export/events/events.ts b/src/app/features/organization/features/maintenance-exports/state/maintenance-export/events/events.ts new file mode 100644 index 000000000..9a16347e3 --- /dev/null +++ b/src/app/features/organization/features/maintenance-exports/state/maintenance-export/events/events.ts @@ -0,0 +1,20 @@ +import { type } from '@ngrx/signals'; +import { eventGroup } from '@ngrx/signals/events'; + +/** + * Constant maintenanceExportStoreEvents + * + * @description + * Same-session acknowledged operations drive editor dismissal and scoped collection refresh. + */ +export const maintenanceExportStoreEvents = eventGroup({ + source: 'MaintenanceExportStore', + events: { + acknowledged: type<{ + readonly organizationId: string; + readonly sessionRevision: number; + readonly kind: 'create' | 'adjust' | 'confirm' | 'reference'; + readonly id: string; + }>(), + }, +}); diff --git a/src/app/features/organization/features/maintenance-exports/state/maintenance-export/index.ts b/src/app/features/organization/features/maintenance-exports/state/maintenance-export/index.ts new file mode 100644 index 000000000..e29ed5bf0 --- /dev/null +++ b/src/app/features/organization/features/maintenance-exports/state/maintenance-export/index.ts @@ -0,0 +1,3 @@ +export { MaintenanceExportStore } from './maintenance-export.store'; +export type { MaintenanceExportStoreType } from './maintenance-export.store'; +export { maintenanceExportStoreEvents } from './events/events'; diff --git a/src/app/features/organization/features/maintenance-exports/state/maintenance-export/maintenance-export.store.ts b/src/app/features/organization/features/maintenance-exports/state/maintenance-export/maintenance-export.store.ts new file mode 100644 index 000000000..f51d77f8f --- /dev/null +++ b/src/app/features/organization/features/maintenance-exports/state/maintenance-export/maintenance-export.store.ts @@ -0,0 +1,569 @@ +import { isPlatformBrowser } from '@angular/common'; +import { computed, effect, inject, PLATFORM_ID, untracked } from '@angular/core'; +import { tapResponse } from '@ngrx/operators'; +import { + patchState, + signalStore, + type, + withComputed, + withHooks, + withMethods, + withState, +} from '@ngrx/signals'; +import { removeAllEntities, setAllEntities, withEntities } from '@ngrx/signals/entities'; +import { Dispatcher } from '@ngrx/signals/events'; +import { rxMethod } from '@ngrx/signals/rxjs-interop'; +import { EMPTY, exhaustMap, map, pipe, switchMap, type Observable } from 'rxjs'; +import { + idleCallState, + pendingCallState, + successCallState, + errorCallState, + toStoreError, +} from '@core/request-state'; +import { AUTH_SESSION_PORT } from '@features/auth/ports'; +import { OrganizationPermissionService } from '@features/organization/access'; +import { CustomerService } from '@features/organization/features/customers/data-access'; +import { EquipmentService } from '@features/organization/features/equipments/data-access'; +import { FacilityService } from '@features/organization/features/facilities/data-access'; +import { MaintenanceExportService } from '@features/organization/features/maintenance-exports/data-access'; +import type { + MaintenanceExportOutput, + MaintenanceExportReferenceOutput, + MaintenanceExportReferencePage, + MaintenanceExportResourceType, +} from '@features/organization/features/maintenance-exports/models'; +import { ORGANIZATION_PERMISSION } from '@features/organization/models'; +import { maintenanceExportStoreEvents } from './events/events'; +import type { + MaintenanceExportScope, + MaintenanceExportState, + MaintenanceExportCommand, +} from './models/maintenance-export-state.interface'; + +/** + * Constant INITIAL_STATE + * + * @description + * Idle browser-private state contains neither session data nor SSR transfer values. + */ +const INITIAL_STATE: MaintenanceExportState = { + financialAccess: false, + scope: null, + generation: 0, + page: 1, + total: 0, + selectedId: null, + listCallState: idleCallState(), + detailCallState: idleCallState(), + sourcesCallState: idleCallState(), + referencesCallState: idleCallState(), + mappingCallState: idleCallState(), + targetsCallState: idleCallState(), + writeCallState: idleCallState(), + downloadCallState: idleCallState(), + command: null, +}; + +/** + * Constant MaintenanceExportStore + * + * @description + * Browser-only archive state fences cancellable reads and accepted receipt-backed writes by + * organization and authenticated session. + */ +export const MaintenanceExportStore = signalStore( + withEntities({ entity: type(), collection: 'export' }), + withState(INITIAL_STATE), + withComputed((store) => ({ + pageCount: computed(() => Math.max(1, Math.ceil(store.total() / 30))), + writePending: computed(() => store.writeCallState().status === 'pending'), + uncertainWrite: computed( + () => + store.writeCallState().status === 'error' && + !!store.command() && + (store.writeCallState().error?.retryable === true || + store.writeCallState().error?.code === 0), + ), + })), + withMethods( + ( + store, + api = inject(MaintenanceExportService), + customers = inject(CustomerService), + facilities = inject(FacilityService), + equipments = inject(EquipmentService), + permissions = inject(OrganizationPermissionService), + session = inject(AUTH_SESSION_PORT), + platform = inject(PLATFORM_ID), + dispatcher = inject(Dispatcher), + ) => { + const readable = (): boolean => + isPlatformBrowser(platform) && + session.isAuthenticated() && + store.scope()?.sessionRevision === session.sessionRevision() && + permissions.hasPermission(ORGANIZATION_PERMISSION.MAINTENANCE_EXPORTS_READ); + const current = (scope: MaintenanceExportScope, generation: number): boolean => + readable() && + scope.sessionRevision === session.sessionRevision() && + generation === store.generation() && + store.financialAccess() === + permissions.hasPermission(ORGANIZATION_PERMISSION.MAINTENANCE_COST_READ) && + scope.organizationId === store.scope()?.organizationId; + const load = rxMethod<{ readonly page: number; readonly system?: string } | null>( + pipe( + switchMap((query) => { + const scope = store.scope(); + if (!query || !scope || !readable()) return EMPTY; + const generation = store.generation(); + patchState( + store, + { page: query.page, listCallState: pendingCallState() }, + removeAllEntities({ collection: 'export' }), + ); + return api + .list(scope.organizationId, { + page: query.page, + itemsPerPage: 30, + params: query.system ? { system: query.system } : {}, + }) + .pipe( + tapResponse({ + next: (collection) => { + if (current(scope, generation)) + patchState( + store, + setAllEntities([...collection.member], { collection: 'export' }), + { total: collection.totalItems, listCallState: successCallState(null) }, + ); + }, + error: (error: unknown) => { + if (current(scope, generation)) + patchState(store, { listCallState: errorCallState(toStoreError(error)) }); + }, + }), + ); + }), + ), + ); + const read = rxMethod( + pipe( + switchMap((id) => { + const scope = store.scope(); + if (!id || !scope || !readable()) { + patchState(store, { selectedId: null, detailCallState: idleCallState() }); + return EMPTY; + } + const generation = store.generation(), + retained = store.selectedId() === id ? store.detailCallState().data : null; + patchState(store, { selectedId: id, detailCallState: pendingCallState(retained) }); + return api.read(scope.organizationId, id).pipe( + tapResponse({ + next: (result) => { + if (current(scope, generation) && result.organizationId === scope.organizationId) + patchState(store, { detailCallState: successCallState(result) }); + }, + error: (error: unknown) => { + if (current(scope, generation)) + patchState(store, { + detailCallState: errorCallState(toStoreError(error), retained), + }); + }, + }), + ); + }), + ), + ); + const loadSources = rxMethod<{ readonly page: number; readonly search?: string } | null>( + pipe( + switchMap((query) => { + const scope = store.scope(); + if (!query || !scope || !readable()) { + patchState(store, { sourcesCallState: idleCallState() }); + return EMPTY; + } + const generation = store.generation(); + patchState(store, { sourcesCallState: pendingCallState() }); + return api.listSources(scope.organizationId, { ...query, itemsPerPage: 30 }).pipe( + tapResponse({ + next: (collection) => { + if (current(scope, generation)) + patchState(store, { sourcesCallState: successCallState(collection) }); + }, + error: (error: unknown) => { + if (current(scope, generation)) + patchState(store, { sourcesCallState: errorCallState(toStoreError(error)) }); + }, + }), + ); + }), + ), + ); + const loadReferences = rxMethod<{ readonly page: number; readonly system?: string } | null>( + pipe( + switchMap((query) => { + const scope = store.scope(); + if (!query || !scope || !readable()) { + patchState(store, { referencesCallState: idleCallState() }); + return EMPTY; + } + const generation = store.generation(); + patchState(store, { referencesCallState: pendingCallState() }); + return api + .listReferences(scope.organizationId, { + page: query.page, + itemsPerPage: 30, + params: query.system ? { system: query.system } : {}, + }) + .pipe( + tapResponse({ + next: (collection) => { + if (current(scope, generation)) + patchState(store, { referencesCallState: successCallState(collection) }); + }, + error: (error: unknown) => { + if (current(scope, generation)) + patchState(store, { + referencesCallState: errorCallState(toStoreError(error)), + }); + }, + }), + ); + }), + ), + ); + const readMapping = rxMethod<{ + readonly system: string; + readonly resourceType: MaintenanceExportResourceType; + readonly resourceId: string; + } | null>( + pipe( + switchMap((query) => { + const scope = store.scope(); + if (!query || !scope || !readable()) { + patchState(store, { mappingCallState: idleCallState() }); + return EMPTY; + } + const generation = store.generation(); + patchState(store, { mappingCallState: pendingCallState() }); + return api + .listReferences(scope.organizationId, { + page: 1, + itemsPerPage: 30, + params: { ...query }, + }) + .pipe( + tapResponse({ + next: (collection) => { + if (current(scope, generation)) + patchState(store, { + mappingCallState: successCallState( + collection.member.find( + (item) => + item.resourceId === query.resourceId && + item.resourceType === query.resourceType && + item.system === query.system, + ) ?? null, + ), + }); + }, + error: (error: unknown) => { + if (current(scope, generation)) + patchState(store, { mappingCallState: errorCallState(toStoreError(error)) }); + }, + }), + ); + }), + ), + ); + const loadTargets = rxMethod<{ + readonly resourceType: MaintenanceExportResourceType; + readonly page: number; + readonly search?: string; + readonly archived?: boolean; + } | null>( + pipe( + switchMap((query) => { + const scope = store.scope(); + if (!query || !scope || !readable()) { + patchState(store, { targetsCallState: idleCallState() }); + return EMPTY; + } + const generation = store.generation(); + const options = { page: query.page, itemsPerPage: 30, search: query.search }; + let request: Observable; + if ( + query.resourceType === 'customer' && + permissions.hasPermission(ORGANIZATION_PERMISSION.CUSTOMERS_READ) + ) + request = customers + .list(scope.organizationId, { + ...options, + params: { archived: query.archived ?? false }, + }) + .pipe( + map((page) => ({ + member: page.member.map((item) => ({ id: item.id, label: item.name })), + totalItems: page.totalItems, + })), + ); + else if ( + query.resourceType === 'site' && + permissions.hasPermission(ORGANIZATION_PERMISSION.FACILITIES_READ) + ) + request = facilities + .list(scope.organizationId, { ...options, rootsOnly: true, includeArchived: true }) + .pipe( + map((page) => ({ + member: page.member + .filter((item) => item.type === 'site') + .map((item) => ({ id: item.id, label: item.name })), + totalItems: page.totalItems, + })), + ); + else if ( + query.resourceType === 'equipment' && + permissions.hasPermission(ORGANIZATION_PERMISSION.EQUIPMENT_READ) + ) + request = equipments.list(scope.organizationId, options).pipe( + map((page) => ({ + member: page.member.map((item) => ({ + id: item.id, + label: item.name ?? item.assetCode ?? item.serialNumber ?? item.type, + })), + totalItems: page.totalItems, + })), + ); + else { + patchState(store, { targetsCallState: idleCallState() }); + return EMPTY; + } + patchState(store, { targetsCallState: pendingCallState() }); + return request.pipe( + tapResponse({ + next: (collection) => { + if (current(scope, generation)) + patchState(store, { targetsCallState: successCallState(collection) }); + }, + error: (error: unknown) => { + if (current(scope, generation)) + patchState(store, { targetsCallState: errorCallState(toStoreError(error)) }); + }, + }), + ); + }), + ), + ); + const request = ( + command: MaintenanceExportCommand, + ): Observable => { + switch (command.kind) { + case 'create': + return api.create(command.organizationId, command.input); + case 'adjust': + return api.adjust( + command.organizationId, + command.exportId, + command.input, + command.revision, + ); + case 'confirm': + return api.confirm( + command.organizationId, + command.exportId, + command.input, + command.revision, + ); + case 'reference': + return api.writeReference( + command.organizationId, + command.resourceType, + command.resourceId, + command.input, + command.revision, + ); + } + }; + const write = rxMethod( + pipe( + exhaustMap((incoming) => { + const scope = store.scope(); + if ( + !scope || + !readable() || + scope.sessionRevision !== session.sessionRevision() || + scope.organizationId !== incoming.organizationId || + !permissions.hasPermission( + incoming.kind === 'confirm' + ? ORGANIZATION_PERMISSION.MAINTENANCE_EXPORTS_CONFIRM + : ORGANIZATION_PERMISSION.MAINTENANCE_EXPORTS_MANAGE, + ) + ) + return EMPTY; + const command = store.uncertainWrite() + ? (store.command() ?? incoming) + : structuredClone(incoming); + const detail = store.detailCallState().data; + if ( + ((command.kind === 'create' && command.input.includeInternalCosts) || + ((command.kind === 'adjust' || command.kind === 'confirm') && + detail?.includeInternalCosts)) && + !permissions.hasPermission(ORGANIZATION_PERMISSION.MAINTENANCE_COST_READ) + ) + return EMPTY; + const generation = store.generation(); + patchState(store, { command, writeCallState: pendingCallState() }); + return request(command).pipe( + tapResponse({ + next: (result) => { + if (!current(scope, generation)) { + patchState(store, { writeCallState: idleCallState(), command: null }); + return; + } + if ( + 'organizationId' in result && + result.organizationId !== scope.organizationId + ) { + patchState(store, { writeCallState: idleCallState(), command: null }); + return; + } + patchState(store, { + writeCallState: successCallState(result), + command: null, + ...('sourceInterventionIds' in result + ? { selectedId: result.id, detailCallState: successCallState(result) } + : { mappingCallState: successCallState(result) }), + }); + dispatcher.dispatch( + maintenanceExportStoreEvents.acknowledged({ + organizationId: scope.organizationId, + sessionRevision: scope.sessionRevision, + kind: command.kind, + id: result.id, + }), + ); + }, + error: (error: unknown) => { + if (current(scope, generation)) + patchState(store, { writeCallState: errorCallState(toStoreError(error)) }); + else patchState(store, { writeCallState: idleCallState(), command: null }); + }, + }), + ); + }), + ), + ); + const download = rxMethod<{ + readonly exportId: string; + readonly format: 'json' | 'csv'; + readonly includeInternalCosts: boolean; + } | null>( + pipe( + switchMap((query) => { + const scope = store.scope(); + if ( + !query || + !scope || + !readable() || + (query.includeInternalCosts && + !permissions.hasPermission(ORGANIZATION_PERMISSION.MAINTENANCE_COST_READ)) + ) + return EMPTY; + const generation = store.generation(); + patchState(store, { downloadCallState: pendingCallState() }); + return api.download(scope.organizationId, query.exportId, query.format).pipe( + tapResponse({ + next: (blob) => { + if ( + current(scope, generation) && + (!query.includeInternalCosts || + permissions.hasPermission(ORGANIZATION_PERMISSION.MAINTENANCE_COST_READ)) + ) + patchState(store, { downloadCallState: successCallState(blob) }); + else patchState(store, { downloadCallState: idleCallState() }); + }, + error: (error: unknown) => { + if (current(scope, generation)) + patchState(store, { downloadCallState: errorCallState(toStoreError(error)) }); + }, + }), + ); + }), + ), + ); + return { + load, + read, + loadSources, + loadReferences, + loadTargets, + readMapping, + write, + download, + setScope(scope: MaintenanceExportScope | null): void { + const financialAccess = permissions.hasPermission( + ORGANIZATION_PERMISSION.MAINTENANCE_COST_READ, + ); + if ( + JSON.stringify(scope) === JSON.stringify(store.scope()) && + financialAccess === store.financialAccess() + ) + return; + const pending = store.writePending(); + patchState(store, INITIAL_STATE, removeAllEntities({ collection: 'export' }), { + scope, + financialAccess, + generation: store.generation() + 1, + writeCallState: pending ? pendingCallState() : idleCallState(), + }); + load(null); + read(null); + loadSources(null); + loadReferences(null); + readMapping(null); + loadTargets(null); + download(null); + }, + clearCommand(): void { + if (!store.writePending() && !store.uncertainWrite()) + patchState(store, { command: null, writeCallState: idleCallState() }); + }, + clearDownload(): void { + patchState(store, { downloadCallState: idleCallState() }); + }, + }; + }, + ), + withHooks( + ( + store, + session = inject(AUTH_SESSION_PORT), + permissions = inject(OrganizationPermissionService), + ) => ({ + onInit(): void { + effect(() => { + const revision = session.sessionRevision(); + if ( + store.scope() && + (store.scope()?.sessionRevision !== revision || + !session.isAuthenticated() || + !permissions.hasPermission(ORGANIZATION_PERMISSION.MAINTENANCE_EXPORTS_READ) || + store.financialAccess() !== + permissions.hasPermission(ORGANIZATION_PERMISSION.MAINTENANCE_COST_READ)) + ) + untracked(() => store.setScope(null)); + }); + }, + }), + ), +); + +/** + * Type MaintenanceExportStoreType + * + * @description + * Route-scoped archive state contract for its owning page. + * + * @type {MaintenanceExportStoreType} + */ +export type MaintenanceExportStoreType = InstanceType; diff --git a/src/app/features/organization/features/maintenance-exports/state/maintenance-export/models/maintenance-export-state.interface.ts b/src/app/features/organization/features/maintenance-exports/state/maintenance-export/models/maintenance-export-state.interface.ts new file mode 100644 index 000000000..209f76b17 --- /dev/null +++ b/src/app/features/organization/features/maintenance-exports/state/maintenance-export/models/maintenance-export-state.interface.ts @@ -0,0 +1,237 @@ +import type { HydraCollection } from '@core/api/models'; +import type { CallState } from '@core/request-state'; +import type { + MaintenanceExportOutput, + MaintenanceExportSourceOutput, + MaintenanceExportReferenceOutput, + MaintenanceExportReferencePage, + MaintenanceExportResourceType, + CreateMaintenanceExportInput, + AdjustMaintenanceExportInput, + ConfirmMaintenanceExportInput, + WriteMaintenanceExportReferenceInput, +} from '@features/organization/features/maintenance-exports/models'; + +/** + * Interface MaintenanceExportScope + * @interface MaintenanceExportScope + * + * @description + * Private workspace identity includes the authenticated session generation. + */ +export interface MaintenanceExportScope { + /** + * Property organizationId + * + * @description + * Active owning organization. + * + * @type {string} + */ + readonly organizationId: string; + /** + * Property sessionRevision + * + * @description + * Authenticated session generation captured at acceptance. + * + * @type {number} + */ + readonly sessionRevision: number; +} + +/** + * Type MaintenanceExportCommand + * + * @description + * Exact accepted operations retain the source revision and stable receipt identity. + * + * @type {MaintenanceExportCommand} + */ +export type MaintenanceExportCommand = + | { + readonly kind: 'create'; + readonly organizationId: string; + readonly input: CreateMaintenanceExportInput; + } + | { + readonly kind: 'adjust'; + readonly organizationId: string; + readonly exportId: string; + readonly revision: number; + readonly input: AdjustMaintenanceExportInput; + } + | { + readonly kind: 'confirm'; + readonly organizationId: string; + readonly exportId: string; + readonly revision: number; + readonly input: ConfirmMaintenanceExportInput; + } + | { + readonly kind: 'reference'; + readonly organizationId: string; + readonly resourceType: MaintenanceExportResourceType; + readonly resourceId: string; + readonly revision: number; + readonly input: WriteMaintenanceExportReferenceInput; + }; + +/** + * Interface MaintenanceExportState + * @interface MaintenanceExportState + * + * @description + * Independent request states keep unavailable reads distinct from confirmed empty pages. + */ +export interface MaintenanceExportState { + /** + * Property financialAccess + * + * @description + * Financial grant captured by this private generation, including archive list authority. + * + * @type {boolean} + */ + readonly financialAccess: boolean; + /** + * Property scope + * @readonly + * + * @description + * Organization and session identity whose private results may be displayed. + * + * @type {MaintenanceExportScope | null} + */ + readonly scope: MaintenanceExportScope | null; + /** + * Property generation + * @readonly + * + * @description + * Local scope generation that rejects replies from an obsolete context. + * + * @type {number} + */ + readonly generation: number; + /** + * Property page + * @readonly + * + * @description + * Current authoritative server page, starting at one. + * + * @type {number} + */ + readonly page: number; + /** + * Property total + * @readonly + * + * @description + * Authoritative server count for the selected archive scope. + * + * @type {number} + */ + readonly total: number; + /** + * Property listCallState + * @readonly + * + * @description + * Archive collection request state; failure remains distinct from empty. + * + * @type {CallState} + */ + readonly listCallState: CallState; + /** + * Property detailCallState + * @readonly + * + * @description + * Retained archive detail state, separate from collection loading. + * + * @type {CallState} + */ + readonly detailCallState: CallState; + /** + * Property sourcesCallState + * @readonly + * + * @description + * Published dossier selector page with server-owned readiness. + * + * @type {CallState>} + */ + readonly sourcesCallState: CallState>; + /** + * Property referencesCallState + * @readonly + * + * @description + * Current external mapping page, independent from immutable archives. + * + * @type {CallState>} + */ + readonly referencesCallState: CallState>; + /** + * Property mappingCallState + * @readonly + * + * @description + * Exact current mapping read; successful absence is revision zero. + * + * @type {CallState} + */ + readonly mappingCallState: CallState; + /** + * Property targetsCallState + * @readonly + * + * @description + * Readable minimal owner directory choices without contacts. + * + * @type {CallState} + */ + readonly targetsCallState: CallState; + /** + * Property writeCallState + * @readonly + * + * @description + * Accepted command state whose receipt survives transport uncertainty. + * + * @type {CallState} + */ + readonly writeCallState: CallState; + /** + * Property downloadCallState + * @readonly + * + * @description + * Retained byte-stream state; the browser never regenerates exports. + * + * @type {CallState} + */ + readonly downloadCallState: CallState; + /** + * Property command + * @readonly + * + * @description + * Frozen operation identity, body and revision retained for an uncertain retry. + * + * @type {MaintenanceExportCommand | null} + */ + readonly command: MaintenanceExportCommand | null; + /** + * Property selectedId + * @readonly + * + * @description + * Archive whose metadata may be retained during a same-target refresh. + * + * @type {string | null} + */ + readonly selectedId: string | null; +} diff --git a/src/app/features/organization/features/maintenance-exports/state/maintenance-export/testing/maintenance-export.store.spec.ts b/src/app/features/organization/features/maintenance-exports/state/maintenance-export/testing/maintenance-export.store.spec.ts new file mode 100644 index 000000000..6462c0d22 --- /dev/null +++ b/src/app/features/organization/features/maintenance-exports/state/maintenance-export/testing/maintenance-export.store.spec.ts @@ -0,0 +1,376 @@ +import { PLATFORM_ID, signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { of, Subject, throwError } from 'rxjs'; +import { AUTH_SESSION_PORT } from '@features/auth/ports'; +import { OrganizationPermissionService } from '@features/organization/access'; +import { CustomerService } from '@features/organization/features/customers/data-access'; +import { EquipmentService } from '@features/organization/features/equipments/data-access'; +import { FacilityService } from '@features/organization/features/facilities/data-access'; +import { MaintenanceExportService } from '@features/organization/features/maintenance-exports/data-access'; +import type { MaintenanceExportOutput } from '@features/organization/features/maintenance-exports/models'; +import { maintenanceExportFixture } from '@features/organization/features/maintenance-exports/models/export/testing/maintenance-export.fixture'; +import { ORGANIZATION_PERMISSION } from '@features/organization/models'; +import { + MaintenanceExportStore, + type MaintenanceExportStoreType, +} from '../maintenance-export.store'; + +describe('MaintenanceExportStore boundaries', () => { + let store: MaintenanceExportStoreType; + const authenticated = signal(true), + revision = signal(1), + grants = signal([]); + let api: Record>; + let customers: { list: ReturnType }, + facilities: { list: ReturnType }, + equipments: { list: ReturnType }; + const archive = maintenanceExportFixture(), + scope = { organizationId: 'org', sessionRevision: 1 }; + const input = { + clientOperationId: 'stable-create', + interventionIds: ['dossier-1'], + system: 'ERP', + includeInternalCosts: false, + }; + const setup = (platform = 'browser'): void => { + TestBed.configureTestingModule({ + providers: [ + MaintenanceExportStore, + { provide: MaintenanceExportService, useValue: api }, + { provide: CustomerService, useValue: customers }, + { provide: FacilityService, useValue: facilities }, + { provide: EquipmentService, useValue: equipments }, + { + provide: OrganizationPermissionService, + useValue: { hasPermission: (permission: string) => grants().includes(permission) }, + }, + { + provide: AUTH_SESSION_PORT, + useValue: { isAuthenticated: authenticated, sessionRevision: revision }, + }, + { provide: PLATFORM_ID, useValue: platform }, + ], + }); + store = TestBed.inject(MaintenanceExportStore); + store.setScope(scope); + }; + beforeEach(() => { + authenticated.set(true); + revision.set(1); + grants.set([ + ORGANIZATION_PERMISSION.MAINTENANCE_EXPORTS_READ, + ORGANIZATION_PERMISSION.MAINTENANCE_EXPORTS_MANAGE, + ORGANIZATION_PERMISSION.MAINTENANCE_EXPORTS_CONFIRM, + ]); + api = { + list: vi.fn().mockReturnValue(of({ member: [archive], totalItems: 1 })), + read: vi.fn().mockReturnValue(of(archive)), + listSources: vi.fn().mockReturnValue(of({ member: [], totalItems: 0 })), + listReferences: vi.fn().mockReturnValue(of({ member: [], totalItems: 0 })), + create: vi.fn().mockReturnValue(of(archive)), + adjust: vi.fn().mockReturnValue( + of( + maintenanceExportFixture({ + id: 'adjusted', + kind: 'adjustment', + adjustmentOf: archive.id, + originalExportId: archive.id, + }), + ), + ), + confirm: vi.fn().mockReturnValue(of(maintenanceExportFixture({ state: 'import_confirmed' }))), + writeReference: vi.fn().mockReturnValue(of({ id: 'mapping', revision: 1 })), + download: vi.fn().mockReturnValue(of(new Blob(['retained']))), + }; + customers = { + list: vi.fn().mockReturnValue( + of({ + member: [{ id: 'customer', name: 'Client', contacts: ['private'] }], + totalItems: 1, + }), + ), + }; + facilities = { list: vi.fn().mockReturnValue(of({ member: [], totalItems: 0 })) }; + equipments = { list: vi.fn().mockReturnValue(of({ member: [], totalItems: 0 })) }; + }); + it.each(['server', 'anonymous', 'no-read'])('fetches no private data for %s', (mode) => { + if (mode === 'anonymous') authenticated.set(false); + if (mode === 'no-read') grants.set([]); + setup(mode === 'server' ? 'server' : 'browser'); + store.load({ page: 1 }); + store.read(archive.id); + store.loadSources({ page: 1 }); + store.loadReferences({ page: 1 }); + expect(api['list']).not.toHaveBeenCalled(); + expect(api['read']).not.toHaveBeenCalled(); + expect(api['listSources']).not.toHaveBeenCalled(); + expect(api['listReferences']).not.toHaveBeenCalled(); + }); + it('cancels a read when the organization changes and rejects old data', () => { + setup(); + const old = new Subject(); + api['read']?.mockReturnValue(old); + store.read(archive.id); + expect(old.observed).toBe(true); + store.setScope({ ...scope, organizationId: 'other' }); + expect(old.observed).toBe(false); + old.next(archive); + expect(store.detailCallState().data).toBeNull(); + }); + it('keeps an accepted write alive while a late result cannot populate another organization', () => { + setup(); + const accepted = new Subject(); + api['create']?.mockReturnValue(accepted); + store.write({ kind: 'create', organizationId: 'org', input }); + store.write({ kind: 'create', organizationId: 'org', input }); + expect(api['create']).toHaveBeenCalledTimes(1); + store.setScope({ ...scope, organizationId: 'other' }); + expect(accepted.observed).toBe(true); + expect(store.writePending()).toBe(true); + accepted.next(archive); + accepted.complete(); + expect(store.detailCallState().data).toBeNull(); + expect(store.writeCallState().data).toBeNull(); + }); + it('replays the same operation key and frozen payload after a lost response', () => { + setup(); + api['create'] + ?.mockReturnValueOnce( + throwError(() => ({ + type: 'about:blank', + status: 0, + title: 'Network', + detail: 'Lost reply', + })), + ) + .mockReturnValue(of(archive)); + const draft = { ...input, interventionIds: [...input.interventionIds] }; + store.write({ kind: 'create', organizationId: 'org', input: draft }); + expect(store.uncertainWrite()).toBe(true); + draft.system = 'changed'; + draft.interventionIds.push('unreviewed'); + store.write({ kind: 'create', organizationId: 'org', input: draft }); + expect(api['create']?.mock.calls[1]?.[1]).toEqual(input); + expect(store.command()).toBeNull(); + }); + it('retains a confirmed stale-revision command for explicit review and does not classify it as an uncertain transport', () => { + setup(); + store.read(archive.id); + api['adjust']?.mockReturnValue( + throwError(() => ({ + type: 'about:blank', + status: 412, + title: 'Changed source', + detail: 'Review latest revision.', + })), + ); + store.write({ + kind: 'adjust', + organizationId: 'org', + exportId: archive.id, + revision: 1, + input: { clientOperationId: 'stable-adjust', reason: 'Validated amendment' }, + }); + expect(store.writeCallState().error?.code).toBe(412); + expect(store.command()).toMatchObject({ revision: 1 }); + expect(store.uncertainWrite()).toBe(false); + expect(store.detailCallState().data).toEqual(archive); + }); + it('requires confirm permission independently from export management', () => { + setup(); + grants.set([ + ORGANIZATION_PERMISSION.MAINTENANCE_EXPORTS_READ, + ORGANIZATION_PERMISSION.MAINTENANCE_EXPORTS_MANAGE, + ]); + store.write({ + kind: 'confirm', + organizationId: 'org', + exportId: archive.id, + revision: 1, + input: { clientOperationId: 'ack', externalImportReference: 'IMPORT-1' }, + }); + expect(api['confirm']).not.toHaveBeenCalled(); + }); + it('requires financial permission independently for cost creation and retained download', () => { + setup(); + store.write({ + kind: 'create', + organizationId: 'org', + input: { ...input, includeInternalCosts: true }, + }); + store.download({ exportId: 'private', format: 'json', includeInternalCosts: true }); + expect(api['create']).not.toHaveBeenCalled(); + expect(api['download']).not.toHaveBeenCalled(); + grants.set([...grants(), ORGANIZATION_PERMISSION.MAINTENANCE_COST_READ]); + store.setScope(scope); + store.write({ + kind: 'create', + organizationId: 'org', + input: { ...input, includeInternalCosts: true }, + }); + expect(api['create']).toHaveBeenCalledTimes(1); + }); + it('does not accept a financial download after its permission is revoked in flight', () => { + setup(); + grants.set([...grants(), ORGANIZATION_PERMISSION.MAINTENANCE_COST_READ]); + store.setScope(scope); + const bytes = new Subject(); + api['download']?.mockReturnValue(bytes); + store.download({ exportId: 'private', format: 'json', includeInternalCosts: true }); + grants.set( + grants().filter((permission) => permission !== ORGANIZATION_PERMISSION.MAINTENANCE_COST_READ), + ); + bytes.next(new Blob(['private'])); + expect(store.downloadCallState().data).toBeNull(); + }); + it.each(['session', 'permission', 'logout'])( + 'clears cached data after %s replacement', + (mode) => { + setup(); + store.load({ page: 1 }); + store.read(archive.id); + if (mode === 'session') revision.set(2); + if (mode === 'permission') grants.set([]); + if (mode === 'logout') authenticated.set(false); + TestBed.tick(); + expect(store.scope()).toBeNull(); + expect(store.exportEntities()).toEqual([]); + expect(store.detailCallState().data).toBeNull(); + }, + ); + it('requests a mapping directory only with its own read permission and keeps contacts out of the projection', () => { + setup(); + store.loadTargets({ resourceType: 'customer', page: 1 }); + expect(customers.list).not.toHaveBeenCalled(); + grants.set([...grants(), ORGANIZATION_PERMISSION.CUSTOMERS_READ]); + store.loadTargets({ resourceType: 'customer', page: 2, search: 'Client' }); + expect(customers.list).toHaveBeenCalledWith('org', { + page: 2, + itemsPerPage: 30, + search: 'Client', + params: { archived: false }, + }); + expect(store.targetsCallState().data?.member).toEqual([{ id: 'customer', label: 'Client' }]); + }); + it('reads archived customers on server pages under the same independent directory permission', () => { + grants.set([...grants(), ORGANIZATION_PERMISSION.CUSTOMERS_READ]); + setup(); + customers.list.mockReturnValue( + of({ + member: [ + { + id: 'archived-customer', + name: 'Archived operator', + archivedAt: '2026-10-07T12:00:00Z', + contacts: ['private'], + }, + ], + totalItems: 61, + }), + ); + store.loadTargets({ resourceType: 'customer', page: 3, search: 'operator', archived: true }); + expect(customers.list).toHaveBeenCalledWith('org', { + page: 3, + itemsPerPage: 30, + search: 'operator', + params: { archived: true }, + }); + expect(store.targetsCallState().data).toEqual({ + member: [{ id: 'archived-customer', label: 'Archived operator' }], + totalItems: 61, + }); + grants.set(grants().filter((grant) => grant !== ORGANIZATION_PERMISSION.CUSTOMERS_READ)); + store.loadTargets({ resourceType: 'customer', page: 1, archived: true }); + expect(customers.list).toHaveBeenCalledOnce(); + expect(store.targetsCallState().data).toBeNull(); + }); + it('distinguishes a successfully absent mapping from a failed read before revision zero can be used', () => { + setup(); + store.readMapping({ system: 'ERP', resourceType: 'equipment', resourceId: 'equipment' }); + expect(store.mappingCallState().status).toBe('success'); + expect(store.mappingCallState().data).toBeNull(); + api['listReferences']?.mockReturnValue( + throwError(() => ({ + type: 'about:blank', + status: 403, + title: 'Forbidden', + detail: 'Mapping read denied.', + })), + ); + store.readMapping({ system: 'ERP', resourceType: 'equipment', resourceId: 'equipment' }); + expect(store.mappingCallState().status).toBe('error'); + expect(store.mappingCallState().error?.code).toBe(403); + }); + + it('clears private list metadata when finance is revoked without a selected detail', () => { + grants.set([...grants(), ORGANIZATION_PERMISSION.MAINTENANCE_COST_READ]); + setup(); + api['list']?.mockReturnValue( + of({ + member: [ + maintenanceExportFixture({ + includeInternalCosts: true, + costsComplete: false, + incompleteCostCount: 1, + }), + ], + totalItems: 1, + }), + ); + store.load({ page: 1 }); + expect(store.exportEntities()).toHaveLength(1); + expect(store.detailCallState().data).toBeNull(); + grants.set(grants().filter((grant) => grant !== ORGANIZATION_PERMISSION.MAINTENANCE_COST_READ)); + TestBed.tick(); + expect(store.exportEntities()).toEqual([]); + expect(store.listCallState().data).toBeNull(); + expect(store.scope()).toBeNull(); + }); + + it('rejects a private pending list reply before permission invalidation has run', () => { + grants.set([...grants(), ORGANIZATION_PERMISSION.MAINTENANCE_COST_READ]); + setup(); + const pending = new Subject<{ member: MaintenanceExportOutput[]; totalItems: number }>(); + api['list']?.mockReturnValue(pending); + store.load({ page: 1 }); + grants.set(grants().filter((grant) => grant !== ORGANIZATION_PERMISSION.MAINTENANCE_COST_READ)); + pending.next({ + member: [maintenanceExportFixture({ includeInternalCosts: true })], + totalItems: 1, + }); + expect(store.exportEntities()).toEqual([]); + TestBed.tick(); + expect(pending.observed).toBe(false); + }); + + it('does not restore a private accepted write receipt after financial access is revoked', () => { + grants.set([...grants(), ORGANIZATION_PERMISSION.MAINTENANCE_COST_READ]); + setup(); + const accepted = new Subject(); + api['create']?.mockReturnValue(accepted); + store.write({ + kind: 'create', + organizationId: 'org', + input: { ...input, includeInternalCosts: true }, + }); + grants.set(grants().filter((grant) => grant !== ORGANIZATION_PERMISSION.MAINTENANCE_COST_READ)); + TestBed.tick(); + expect(accepted.observed).toBe(true); + accepted.next(maintenanceExportFixture({ includeInternalCosts: true })); + expect(store.writeCallState().data).toBeNull(); + expect(store.detailCallState().data).toBeNull(); + }); + + it('cancels an obsolete download read so the new organization can request its own retained bytes', () => { + setup(); + const obsolete = new Subject(); + api['download']?.mockReturnValueOnce(obsolete); + store.download({ exportId: 'old-export', format: 'csv', includeInternalCosts: false }); + expect(obsolete.observed).toBe(true); + store.setScope({ ...scope, organizationId: 'other' }); + expect(obsolete.observed).toBe(false); + store.download({ exportId: 'other-export', format: 'json', includeInternalCosts: false }); + expect(api['download']).toHaveBeenLastCalledWith('other', 'other-export', 'json'); + expect(store.downloadCallState().status).toBe('success'); + }); +}); diff --git a/src/app/features/organization/features/maintenance-exports/ui/forms/maintenance-export-action-form/index.ts b/src/app/features/organization/features/maintenance-exports/ui/forms/maintenance-export-action-form/index.ts new file mode 100644 index 000000000..571974a27 --- /dev/null +++ b/src/app/features/organization/features/maintenance-exports/ui/forms/maintenance-export-action-form/index.ts @@ -0,0 +1 @@ +export { MaintenanceExportActionForm } from './maintenance-export-action-form.component'; diff --git a/src/app/features/organization/features/maintenance-exports/ui/forms/maintenance-export-action-form/maintenance-export-action-form.component.html b/src/app/features/organization/features/maintenance-exports/ui/forms/maintenance-export-action-form/maintenance-export-action-form.component.html new file mode 100644 index 000000000..2b0e39ab6 --- /dev/null +++ b/src/app/features/organization/features/maintenance-exports/ui/forms/maintenance-export-action-form/maintenance-export-action-form.component.html @@ -0,0 +1,76 @@ +
+
+ @if (confirmation()) { + + +

+ Confirm only after your ERP has imported this exact archive. FireGuard does not send or + import it automatically. +

+ } @else { + + +

+ Validated corrections produce a new linked archive. The original file and published dossier + remain unchanged. +

+ } + @if (fields.value().touched()) { + @for (error of fields.value().errors(); track error) { + {{ error.message }} + } + } +
+ @if (error(); as error) { +
+

{{ error.message }}

+
+ } +
+ +
+
diff --git a/src/app/features/organization/features/maintenance-exports/ui/forms/maintenance-export-action-form/maintenance-export-action-form.component.ts b/src/app/features/organization/features/maintenance-exports/ui/forms/maintenance-export-action-form/maintenance-export-action-form.component.ts new file mode 100644 index 000000000..2227dbcc2 --- /dev/null +++ b/src/app/features/organization/features/maintenance-exports/ui/forms/maintenance-export-action-form/maintenance-export-action-form.component.ts @@ -0,0 +1,237 @@ +import { + ChangeDetectionStrategy, + Component, + effect, + input, + output, + signal, + type InputSignal, + type OutputEmitterRef, + type WritableSignal, +} from '@angular/core'; +import { + disabled, + form, + FormField, + FormRoot, + required, + validate, + type FieldTree, +} from '@angular/forms/signals'; +import type { StoreError } from '@core/request-state'; +import { HlmAlertImports } from '@shared/ui/alert'; +import { HlmButton } from '@shared/ui/button'; +import { HlmFieldImports } from '@shared/ui/field'; +import { HlmInput } from '@shared/ui/input'; +import { HlmSpinner } from '@shared/ui/spinner'; +import { HlmTextarea } from '@shared/ui/textarea'; + +/** + * Class MaintenanceExportActionForm + * @class MaintenanceExportActionForm + * + * @description + * Explicit adjustment reason or actual import reference is emitted as intent; receipts belong to + * the store. + */ +@Component({ + selector: 'app-maintenance-export-action-form', + templateUrl: './maintenance-export-action-form.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + FormField, + FormRoot, + HlmButton, + HlmInput, + HlmSpinner, + HlmTextarea, + ...HlmFieldImports, + ...HlmAlertImports, + ], +}) +export class MaintenanceExportActionForm { + //#region Properties + /** + * Property confirmation + * @readonly + * + * @description + * Whether this form records an actual external import reference. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly confirmation: InputSignal = input(false); + /** + * Property locked + * @readonly + * + * @description + * Whether editing is prohibited by permissions, connectivity or receipt recovery. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly locked: InputSignal = input(false); + /** + * Property pending + * @readonly + * + * @description + * Whether an accepted command is waiting for its acknowledgement. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly pending: InputSignal = input(false); + /** + * Property error + * @readonly + * + * @description + * Recoverable server rejection shown beside the unchanged draft. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly error: InputSignal = input(null); + /** + * Property submitted + * @readonly + * + * @description + * Validated native form intent; the owning page accepts the write. + * + * @access public + * @since unreleased + * + * @type {OutputEmitterRef} + */ + public readonly submitted: OutputEmitterRef = output(); + /** + * Property cancelled + * @readonly + * + * @description + * Intent to dismiss the draft through the owning page. + * + * @access public + * @since unreleased + * + * @type {OutputEmitterRef} + */ + public readonly cancelled: OutputEmitterRef = output(); + /** + * Property dirtyChanged + * @readonly + * + * @description + * Native form dirtiness used by guarded dismissal. + * + * @access public + * @since unreleased + * + * @type {OutputEmitterRef} + */ + public readonly dirtyChanged: OutputEmitterRef = output(); + /** + * Property draft + * @readonly + * + * @description + * Editable native form values; exact strings remain unchanged until validation. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal<{ value: string }>} + */ + protected readonly draft: WritableSignal<{ value: string }> = signal({ value: '' }); + /** + * Property fields + * @readonly + * + * @description + * Signal Forms schema, validation and real field state. + * + * @access protected + * @since unreleased + * + * @type {FieldTree<{ value: string }>} + */ + protected readonly fields: FieldTree<{ value: string }> = form(this.draft, (path) => { + disabled(path, () => this.locked() || this.pending()); + required(path.value, { + message: $localize`:@@maintenanceExport.action.required:Enter the reason or actual external import reference.`, + }); + validate(path.value, ({ value }) => { + const limit = this.confirmation() ? 200 : 1000; + return Array.from(value().trim()).length <= limit + ? null + : { + kind: 'maxLength', + message: this.confirmation() + ? $localize`:@@maintenanceExport.form.limit200:Use no more than 200 characters.` + : $localize`:@@maintenanceExport.form.limit1000:Use no more than 1000 characters.`, + }; + }); + validate(path.value, ({ value }) => + value().trim() + ? null + : { + kind: 'required', + message: $localize`:@@maintenanceExport.action.required:Enter the reason or actual external import reference.`, + }, + ); + }); + + //#endregion + + //#region Constructor + /** + * Constructor + * @constructor + * + * @description + * Connects native draft, authenticated scope and acknowledged command consequences. + * + * @access public + * @since unreleased + */ + public constructor() { + effect(() => this.dirtyChanged.emit(this.fields().dirty())); + } + + //#endregion + + //#region Methods + /** + * Method submit + * @method submit + * + * @description + * Validates native field state and emits the exact accepted form intent. + * + * @access protected + * @since unreleased + * + * @param {Event} event - Native control event. + * + * @returns {void} No return value. + */ + protected submit(event: Event): void { + event.preventDefault(); + this.fields().markAsTouched(); + if (!this.fields().invalid() && !this.fields().disabled()) + this.submitted.emit(this.draft().value.trim()); + } + //#endregion +} diff --git a/src/app/features/organization/features/maintenance-exports/ui/forms/maintenance-export-action-form/testing/maintenance-export-action-form.component.spec.ts b/src/app/features/organization/features/maintenance-exports/ui/forms/maintenance-export-action-form/testing/maintenance-export-action-form.component.spec.ts new file mode 100644 index 000000000..615dc9671 --- /dev/null +++ b/src/app/features/organization/features/maintenance-exports/ui/forms/maintenance-export-action-form/testing/maintenance-export-action-form.component.spec.ts @@ -0,0 +1,48 @@ +import { TestBed, type ComponentFixture } from '@angular/core/testing'; +import { MaintenanceExportActionForm } from '../maintenance-export-action-form.component'; + +describe('MaintenanceExportActionForm contract limits', () => { + let fixture: ComponentFixture, submitted: string[]; + const submit = async (text: string): Promise => { + const element = (fixture.nativeElement as HTMLElement).querySelector< + HTMLInputElement | HTMLTextAreaElement + >('#maintenance-export-action-value'); + if (!element) throw new Error('Native action field missing.'); + element.value = text; + element.dispatchEvent(new Event('input', { bubbles: true })); + await fixture.whenStable(); + (fixture.nativeElement as HTMLElement) + .querySelector('form') + ?.dispatchEvent(new Event('submit', { cancelable: true })); + await fixture.whenStable(); + }; + beforeEach(async () => { + fixture = TestBed.createComponent(MaintenanceExportActionForm); + submitted = []; + fixture.componentInstance.submitted.subscribe((value) => submitted.push(value)); + await fixture.whenStable(); + }); + it('accepts adjustment reasons up to 1000 characters and rejects the next character', async () => { + await submit('R'.repeat(1000)); + await submit('R'.repeat(1001)); + expect(submitted).toEqual(['R'.repeat(1000)]); + }); + it('accepts an actual import reference up to 200 characters and rejects 201', async () => { + fixture.componentRef.setInput('confirmation', true); + await fixture.whenStable(); + await submit('I'.repeat(200)); + await submit('I'.repeat(201)); + expect(submitted).toEqual(['I'.repeat(200)]); + }); + it('preserves a non-ASCII reason within its character limit', async () => { + await submit('é'.repeat(1000)); + expect(submitted).toEqual(['é'.repeat(1000)]); + }); + it('counts Unicode code points consistently with the server for supplementary characters', async () => { + fixture.componentRef.setInput('confirmation', true); + await fixture.whenStable(); + await submit('🔥'.repeat(200)); + await submit('🔥'.repeat(201)); + expect(submitted).toEqual(['🔥'.repeat(200)]); + }); +}); diff --git a/src/app/features/organization/features/maintenance-exports/ui/forms/maintenance-export-create-form/index.ts b/src/app/features/organization/features/maintenance-exports/ui/forms/maintenance-export-create-form/index.ts new file mode 100644 index 000000000..6fd36ac95 --- /dev/null +++ b/src/app/features/organization/features/maintenance-exports/ui/forms/maintenance-export-create-form/index.ts @@ -0,0 +1 @@ +export { MaintenanceExportCreateForm } from './maintenance-export-create-form.component'; diff --git a/src/app/features/organization/features/maintenance-exports/ui/forms/maintenance-export-create-form/maintenance-export-create-form.component.html b/src/app/features/organization/features/maintenance-exports/ui/forms/maintenance-export-create-form/maintenance-export-create-form.component.html new file mode 100644 index 000000000..5376069f9 --- /dev/null +++ b/src/app/features/organization/features/maintenance-exports/ui/forms/maintenance-export-create-form/maintenance-export-create-form.component.html @@ -0,0 +1,175 @@ +
+
+ + +

+ Identifies your ERP or target system. References are frozen when the export is generated. +

+ @if (fields.system().touched()) { + @for (error of fields.system().errors(); track error) { + {{ error.message }} + } + } +
+
+ Published dossiers +
+ +
+ + +
+
+ @if (sources().status === 'pending') { + + } + @if (sources().error; as error) { +
+

{{ error.message }}

+
+ } + @if (sources().data; as collection) { +
+ @for (source of collection.member; track source.id) { +
+ + +
+ } + @if (collection.member.length === 0) { +

+ No published dossier matches this search. +

+ } +
+
+ + Page {{ sourcePage() }} + +
+ } +

+ {{ selection().length }} dossier(s) selected. Selection is kept across pages. +

+ @if (fields.interventionIds().touched()) { + @for (error of fields.interventionIds().errors(); track error) { + {{ error.message }} + } + } +
+ @if (canIncludeCosts()) { +
+ + +
+

+ Unknown costs remain explicitly incomplete. This archive will require financial read + permission. +

+ } + @if (error(); as error) { +
+

{{ error.message }}

+
+ } +
+ + +
+
diff --git a/src/app/features/organization/features/maintenance-exports/ui/forms/maintenance-export-create-form/maintenance-export-create-form.component.ts b/src/app/features/organization/features/maintenance-exports/ui/forms/maintenance-export-create-form/maintenance-export-create-form.component.ts new file mode 100644 index 000000000..47aff7abc --- /dev/null +++ b/src/app/features/organization/features/maintenance-exports/ui/forms/maintenance-export-create-form/maintenance-export-create-form.component.ts @@ -0,0 +1,443 @@ +import { + ChangeDetectionStrategy, + Component, + effect, + input, + output, + signal, + untracked, + type InputSignal, + type OutputEmitterRef, + type WritableSignal, +} from '@angular/core'; +import { + disabled, + form, + FormField, + FormRoot, + maxLength, + required, + validate, + type FieldTree, +} from '@angular/forms/signals'; +import type { HydraCollection } from '@core/api/models'; +import type { CallState, StoreError } from '@core/request-state'; +import type { + MaintenanceExportSourceOutput, + CreateMaintenanceExportInput, +} from '@features/organization/features/maintenance-exports/models'; +import { HlmAlertImports } from '@shared/ui/alert'; +import { HlmButton } from '@shared/ui/button'; +import { HlmCheckbox } from '@shared/ui/checkbox'; +import { HlmFieldImports } from '@shared/ui/field'; +import { HlmInput } from '@shared/ui/input'; +import { HlmSpinner } from '@shared/ui/spinner'; + +/** + * Class MaintenanceExportCreateForm + * @class MaintenanceExportCreateForm + * + * @description + * Native creation draft keeps published choices across server pages and emits bounded exact intent. + */ +@Component({ + selector: 'app-maintenance-export-create-form', + templateUrl: './maintenance-export-create-form.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + FormField, + FormRoot, + HlmButton, + HlmInput, + HlmSpinner, + HlmCheckbox, + ...HlmFieldImports, + ...HlmAlertImports, + ], +}) +export class MaintenanceExportCreateForm { + //#region Properties + /** + * Property sources + * @readonly + * + * @description + * Server-paginated published dossier choices and readiness state. + * + * @access public + * @since unreleased + * + * @type {InputSignal>>} + */ + public readonly sources: InputSignal>> = + input.required(); + /** + * Property sourcePage + * @readonly + * + * @description + * Current published dossier selector page. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly sourcePage: InputSignal = input(1); + /** + * Property pending + * @readonly + * + * @description + * Whether an accepted command is waiting for its acknowledgement. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly pending: InputSignal = input(false); + /** + * Property locked + * @readonly + * + * @description + * Whether editing is prohibited by permissions, connectivity or receipt recovery. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly locked: InputSignal = input(false); + /** + * Property canIncludeCosts + * @readonly + * + * @description + * Independent financial permission enabling the explicit private cost opt-in. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly canIncludeCosts: InputSignal = input(false); + /** + * Property error + * @readonly + * + * @description + * Recoverable server rejection shown beside the unchanged draft. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly error: InputSignal = input(null); + /** + * Property submitted + * @readonly + * + * @description + * Validated native form intent; the owning page accepts the write. + * + * @access public + * @since unreleased + * + * @type {OutputEmitterRef< + * Omit + * >} + */ + public readonly submitted: OutputEmitterRef< + Omit + > = output(); + /** + * Property cancelled + * @readonly + * + * @description + * Intent to dismiss the draft through the owning page. + * + * @access public + * @since unreleased + * + * @type {OutputEmitterRef} + */ + public readonly cancelled: OutputEmitterRef = output(); + /** + * Property searchChanged + * @readonly + * + * @description + * Committed published dossier search intent. + * + * @access public + * @since unreleased + * + * @type {OutputEmitterRef} + */ + public readonly searchChanged: OutputEmitterRef = output(); + /** + * Property pageChanged + * @readonly + * + * @description + * Intent to select a different authoritative server page. + * + * @access public + * @since unreleased + * + * @type {OutputEmitterRef} + */ + public readonly pageChanged: OutputEmitterRef = output(); + /** + * Property dirtyChanged + * @readonly + * + * @description + * Native form dirtiness used by guarded dismissal. + * + * @access public + * @since unreleased + * + * @type {OutputEmitterRef} + */ + public readonly dirtyChanged: OutputEmitterRef = output(); + /** + * Property search + * @readonly + * + * @description + * Uncommitted search text, separate from the server query. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal} + */ + protected readonly search: WritableSignal = signal(''); + /** + * Property selection + * @readonly + * + * @description + * Readable published choices retained across selector pages. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal} + */ + protected readonly selection: WritableSignal = signal( + [], + ); + /** + * Property draft + * @readonly + * + * @description + * Editable native form values; exact strings remain unchanged until validation. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal<{ + * system: string; + * includeInternalCosts: boolean; + * interventionIds: string[]; + * }>} + */ + protected readonly draft: WritableSignal<{ + system: string; + includeInternalCosts: boolean; + interventionIds: string[]; + }> = signal({ system: '', includeInternalCosts: false, interventionIds: [] }); + /** + * Property fields + * @readonly + * + * @description + * Signal Forms schema, validation and real field state. + * + * @access protected + * @since unreleased + * + * @type {FieldTree<{ + * system: string; + * includeInternalCosts: boolean; + * interventionIds: string[]; + * }>} + */ + protected readonly fields: FieldTree<{ + system: string; + includeInternalCosts: boolean; + interventionIds: string[]; + }> = form(this.draft, (path) => { + disabled(path, () => this.pending() || this.locked()); + required(path.system, { + message: $localize`:@@maintenanceExport.form.systemRequired:Enter the external system code.`, + }); + maxLength(path.system, 40); + validate(path.system, ({ value }) => + /^[A-Za-z0-9][A-Za-z0-9_.-]{0,39}$/.test(value().trim()) + ? null + : { + kind: 'system', + message: $localize`:@@maintenanceExport.form.systemInvalid:Use letters, numbers, dots, underscores or hyphens (up to 40 characters).`, + }, + ); + validate(path.interventionIds, ({ value }) => + value().length >= 1 && value().length <= 100 + ? null + : { + kind: 'selection', + message: $localize`:@@maintenanceExport.form.selectionRequired:Select between 1 and 100 ready published dossiers.`, + }, + ); + disabled(path.includeInternalCosts, () => !this.canIncludeCosts()); + }); + + //#endregion + + //#region Constructor + /** + * Constructor + * @constructor + * + * @description + * Connects native draft, authenticated scope and acknowledged command consequences. + * + * @access public + * @since unreleased + */ + public constructor() { + effect(() => this.dirtyChanged.emit(this.fields().dirty())); + effect(() => { + if (!this.canIncludeCosts()) + untracked(() => this.draft.update((draft) => ({ ...draft, includeInternalCosts: false }))); + }); + } + + //#endregion + + //#region Methods + /** + * Method toggle + * @method toggle + * + * @description + * Updates only ready published selections and marks the native form dirty. + * + * @access protected + * @since unreleased + * + * @param {MaintenanceExportSourceOutput} source - Explicit source supplied by the owning + * workflow. + * @param {boolean} checked - Explicit checked supplied by the owning workflow. + * + * @returns {void} No return value. + */ + protected toggle(source: MaintenanceExportSourceOutput, checked: boolean): void { + if (this.fields().disabled() || !source.ready) return; + this.selection.update((items) => + checked + ? items.some((item) => item.id === source.id) + ? items + : [...items, source] + : items.filter((item) => item.id !== source.id), + ); + this.draft.update((draft) => ({ + ...draft, + interventionIds: this.selection().map((item) => item.id), + })); + this.fields().markAsDirty(); + } + + /** + * Method selected + * @method selected + * + * @description + * Checks whether a published identity is retained in the cross-page draft. + * + * @access protected + * @since unreleased + * + * @param {string} id - Explicit id supplied by the owning workflow. + * + * @returns {boolean} Result for the current authorized archive workflow. + */ + protected selected(id: string): boolean { + return this.draft().interventionIds.includes(id); + } + + /** + * Method blockedLabel + * @method blockedLabel + * + * @description + * Explains the server readiness blocker without inventing compliance status. + * + * @access protected + * @since unreleased + * + * @param {MaintenanceExportSourceOutput} source - Explicit source supplied by the owning + * workflow. + * + * @returns {string} Result for the current authorized archive workflow. + */ + protected blockedLabel(source: MaintenanceExportSourceOutput): string { + const labels: Readonly> = { + snapshot_missing: $localize`:@@maintenanceExport.form.snapshotMissing:Historical publication snapshot missing. This dossier cannot be exported.`, + no_validated_work: $localize`:@@maintenanceExport.form.noValidatedWork:No validated work is retained in this dossier. It cannot be exported.`, + }; + return labels[source.blockedReason ?? 'snapshot_missing'] ?? labels['snapshot_missing'] ?? ''; + } + + /** + * Method searchInput + * @method searchInput + * + * @description + * Retains uncommitted search text without issuing transport requests. + * + * @access protected + * @since unreleased + * + * @param {Event} event - Native control event. + * + * @returns {void} No return value. + */ + protected searchInput(event: Event): void { + const target = event.target; + if (target instanceof HTMLInputElement) this.search.set(target.value); + } + + /** + * Method submit + * @method submit + * + * @description + * Validates native field state and emits the exact accepted form intent. + * + * @access protected + * @since unreleased + * + * @param {Event} event - Native control event. + * + * @returns {void} No return value. + */ + protected submit(event: Event): void { + event.preventDefault(); + this.fields().markAsTouched(); + if (this.fields().invalid() || this.fields().disabled()) return; + this.submitted.emit({ + system: this.draft().system.trim(), + includeInternalCosts: this.canIncludeCosts() && this.draft().includeInternalCosts, + interventionIds: [...this.draft().interventionIds], + }); + } + //#endregion +} diff --git a/src/app/features/organization/features/maintenance-exports/ui/forms/maintenance-export-create-form/testing/maintenance-export-create-form.component.spec.ts b/src/app/features/organization/features/maintenance-exports/ui/forms/maintenance-export-create-form/testing/maintenance-export-create-form.component.spec.ts new file mode 100644 index 000000000..e43b38c51 --- /dev/null +++ b/src/app/features/organization/features/maintenance-exports/ui/forms/maintenance-export-create-form/testing/maintenance-export-create-form.component.spec.ts @@ -0,0 +1,133 @@ +import { TestBed, type ComponentFixture } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { successCallState } from '@core/request-state'; +import type { + CreateMaintenanceExportInput, + MaintenanceExportSourceOutput, +} from '@features/organization/features/maintenance-exports/models'; +import { HlmCheckbox } from '@shared/ui/checkbox'; +import { MaintenanceExportCreateForm } from '../maintenance-export-create-form.component'; + +const source = (id: string, ready = true): MaintenanceExportSourceOutput => ({ + '@id': '/sources/' + id, + '@type': 'MaintenanceExportSource', + id, + number: 1, + name: 'Annual control ' + id, + type: 'inspection', + publishedAt: '2026-10-07T12:00:00Z', + publicationId: ready ? 'publication' : null, + site: { id: 'site', name: 'Site A' }, + customer: null, + snapshotState: ready ? 'complete' : 'missing', + identityComplete: true, + ready, + blockedReason: ready ? null : 'snapshot_missing', +}); + +describe('MaintenanceExportCreateForm', () => { + let fixture: ComponentFixture, + submissions: Omit[]; + const root = (): HTMLElement => fixture.nativeElement as HTMLElement; + const fillSystem = async (value: string): Promise => { + const input = root().querySelector('#maintenance-export-system'); + if (!input) throw new Error('Required native input missing.'); + input.value = value; + input.dispatchEvent(new Event('input', { bubbles: true })); + await fixture.whenStable(); + }; + const select = async (id: string): Promise => { + fixture.debugElement + .queryAll(By.directive(HlmCheckbox)) + .find((item) => item.componentInstance.inputId() === 'export-source-' + id) + ?.componentInstance.checkedChange.emit(true); + await fixture.whenStable(); + }; + const submit = async (): Promise => { + root() + .querySelector('form') + ?.dispatchEvent(new Event('submit', { cancelable: true })); + await fixture.whenStable(); + }; + beforeEach(async () => { + fixture = TestBed.createComponent(MaintenanceExportCreateForm); + submissions = []; + fixture.componentRef.setInput( + 'sources', + successCallState({ + '@id': '/sources', + '@type': 'Collection', + member: [source('ready'), source('blocked', false)], + totalItems: 2, + }), + ); + fixture.componentInstance.submitted.subscribe((value) => submissions.push(value)); + await fixture.whenStable(); + }); + it('blocks a legacy publication without its retained snapshot and emits only ready selected dossiers', async () => { + await fillSystem('ERP'); + await select('blocked'); + await submit(); + expect(submissions).toEqual([]); + await select('ready'); + await submit(); + expect(submissions).toEqual([ + { system: 'ERP', includeInternalCosts: false, interventionIds: ['ready'] }, + ]); + expect(root().textContent).toContain('Historical publication snapshot missing'); + }); + it('retains a selected dossier across server pages and a recoverable server failure', async () => { + await fillSystem('ERP'); + await select('ready'); + fixture.componentRef.setInput( + 'sources', + successCallState({ + '@id': '/sources', + '@type': 'Collection', + member: [source('page-two')], + totalItems: 40, + }), + ); + fixture.componentRef.setInput('sourcePage', 2); + await fixture.whenStable(); + await select('page-two'); + await submit(); + expect(submissions[0]?.interventionIds).toEqual(['ready', 'page-two']); + fixture.componentRef.setInput('error', { message: 'Lost reply', code: 0, retryable: true }); + await fixture.whenStable(); + expect(root().querySelector('#maintenance-export-system')?.value).toBe('ERP'); + }); + it('requires an explicit valid system code and rejects whitespace or an empty selection', async () => { + await fillSystem(' '); + await select('ready'); + await submit(); + expect(submissions).toEqual([]); + await fillSystem('ERP with spaces'); + await submit(); + expect(submissions).toEqual([]); + await fillSystem('ERP-1'); + await submit(); + expect(submissions).toHaveLength(1); + }); + it('omits the financial opt-in when financial permission is absent', async () => { + expect(root().querySelector('#maintenance-export-costs')).toBeNull(); + fixture.componentRef.setInput('canIncludeCosts', true); + await fixture.whenStable(); + expect(root().querySelector('#maintenance-export-costs')).not.toBeNull(); + fixture.componentRef.setInput('canIncludeCosts', false); + await fixture.whenStable(); + await fillSystem('ERP'); + await select('ready'); + await submit(); + expect(submissions[0]?.includeInternalCosts).toBe(false); + }); + it('locks all form intents while an accepted operation is pending', async () => { + await fillSystem('ERP'); + await select('ready'); + fixture.componentRef.setInput('pending', true); + await fixture.whenStable(); + await submit(); + expect(submissions).toEqual([]); + expect(root().querySelector('button[type=submit]')?.disabled).toBe(true); + }); +}); diff --git a/src/app/features/organization/features/maintenance-exports/ui/forms/maintenance-export-reference-form/index.ts b/src/app/features/organization/features/maintenance-exports/ui/forms/maintenance-export-reference-form/index.ts new file mode 100644 index 000000000..5856fc22f --- /dev/null +++ b/src/app/features/organization/features/maintenance-exports/ui/forms/maintenance-export-reference-form/index.ts @@ -0,0 +1 @@ +export { MaintenanceExportReferenceForm } from './maintenance-export-reference-form.component'; diff --git a/src/app/features/organization/features/maintenance-exports/ui/forms/maintenance-export-reference-form/maintenance-export-reference-form.component.html b/src/app/features/organization/features/maintenance-exports/ui/forms/maintenance-export-reference-form/maintenance-export-reference-form.component.html new file mode 100644 index 000000000..ef31bbce6 --- /dev/null +++ b/src/app/features/organization/features/maintenance-exports/ui/forms/maintenance-export-reference-form/maintenance-export-reference-form.component.html @@ -0,0 +1,237 @@ +
+

+ Mapping changes affect future exports. Retained archives keep their original external + references. +

+
+ + @if (fields.system().touched()) { + @for (error of fields.system().errors(); track error) { + {{ error.message }} + } + } +
+ @if (allowedTypes().length === 0) { +
+

+ A readable customer, site or equipment directory permission is required to choose a mapping + target. +

+
+ } @else { +
+ + + @for (type of allowedTypes(); track type) { + {{ typeLabelOf(type) }} + } + +
+ @if (fields.resourceType().value() === 'customer') { +
+ + Customer status + + + + + +

+ Archived customers can receive references for historical exports. +

+
+ } +
+ +
+ +
+
+
+ + + @for (target of targets().data?.member ?? []; track target.id) { + {{ target.label }} + } + + @if (fields.resourceId().touched()) { + @for (error of fields.resourceId().errors(); track error) { + {{ error.message }} + } + } +
+ @if (targets().status === 'pending') { + + } + @if (targets().error; as error) { +
+

{{ error.message }}

+
+ } + @if (targets().data; as collection) { +
+ Page {{ targetPage() }} +
+ } +
+ + @if (fields.reference().touched()) { + @for (error of fields.reference().errors(); track error) { + {{ error.message }} + } + } +
+ @if (mapping().status === 'pending') { + + } + @if (mapping().error; as error) { +
+

{{ error.message }}

+
+ } + } + @if (error(); as error) { +
+

{{ error.message }}

+
+ } +
+ +
+ diff --git a/src/app/features/organization/features/maintenance-exports/ui/forms/maintenance-export-reference-form/maintenance-export-reference-form.component.ts b/src/app/features/organization/features/maintenance-exports/ui/forms/maintenance-export-reference-form/maintenance-export-reference-form.component.ts new file mode 100644 index 000000000..22d10301f --- /dev/null +++ b/src/app/features/organization/features/maintenance-exports/ui/forms/maintenance-export-reference-form/maintenance-export-reference-form.component.ts @@ -0,0 +1,584 @@ +import { + ChangeDetectionStrategy, + Component, + effect, + input, + output, + signal, + untracked, + type InputSignal, + type OutputEmitterRef, + type WritableSignal, +} from '@angular/core'; +import { + disabled, + form, + FormField, + FormRoot, + maxLength, + required, + validate, + type FieldTree, +} from '@angular/forms/signals'; +import type { CallState, StoreError } from '@core/request-state'; +import type { + MaintenanceExportReferenceOutput, + MaintenanceExportResourceType, + MaintenanceExportReferencePage, +} from '@features/organization/features/maintenance-exports/models'; +import { HlmAlertImports } from '@shared/ui/alert'; +import { HlmButton } from '@shared/ui/button'; +import { HlmFieldImports } from '@shared/ui/field'; +import { HlmInput } from '@shared/ui/input'; +import { HlmSelectImports } from '@shared/ui/select'; +import { HlmSpinner } from '@shared/ui/spinner'; +import { HlmToggleGroupImports } from '@shared/ui/toggle-group'; + +/** + * Class MaintenanceExportReferenceForm + * @class MaintenanceExportReferenceForm + * + * @description + * Selects readable scoped owner identities and emits a reviewed optimistic mapping intent. + */ +@Component({ + selector: 'app-maintenance-export-reference-form', + templateUrl: './maintenance-export-reference-form.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + FormField, + FormRoot, + HlmButton, + HlmInput, + HlmSpinner, + ...HlmSelectImports, + ...HlmFieldImports, + ...HlmAlertImports, + ...HlmToggleGroupImports, + ], +}) +export class MaintenanceExportReferenceForm { + //#region Properties + /** + * Property targets + * @readonly + * + * @description + * Readable authorized owner directory page. + * + * @access public + * @since unreleased + * + * @type {InputSignal< + * CallState + * >} + */ + public readonly targets: InputSignal> = + input.required(); + /** + * Property mapping + * @readonly + * + * @description + * Current scoped mapping read used as optimistic revision authority. + * + * @access public + * @since unreleased + * + * @type {InputSignal>} + */ + public readonly mapping: InputSignal> = + input.required(); + /** + * Property targetPage + * @readonly + * + * @description + * Current resource directory page. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly targetPage: InputSignal = input(1); + /** + * Property allowedTypes + * @readonly + * + * @description + * Owner types whose separate directory read permissions are granted. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly allowedTypes: InputSignal = + input.required(); + /** + * Property locked + * @readonly + * + * @description + * Whether editing is prohibited by permissions, connectivity or receipt recovery. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly locked: InputSignal = input(false); + /** + * Property pending + * @readonly + * + * @description + * Whether an accepted command is waiting for its acknowledgement. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly pending: InputSignal = input(false); + /** + * Property error + * @readonly + * + * @description + * Recoverable server rejection shown beside the unchanged draft. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly error: InputSignal = input(null); + /** + * Property submitted + * @readonly + * + * @description + * Validated native form intent; the owning page accepts the write. + * + * @access public + * @since unreleased + * + * @type {OutputEmitterRef<{ + * readonly resourceType: MaintenanceExportResourceType; + * readonly resourceId: string; + * readonly system: string; + * readonly reference: string; + * readonly revision: number; + * }>} + */ + public readonly submitted: OutputEmitterRef<{ + readonly resourceType: MaintenanceExportResourceType; + readonly resourceId: string; + readonly system: string; + readonly reference: string; + readonly revision: number; + }> = output(); + /** + * Property cancelled + * @readonly + * + * @description + * Intent to dismiss the draft through the owning page. + * + * @access public + * @since unreleased + * + * @type {OutputEmitterRef} + */ + public readonly cancelled: OutputEmitterRef = output(); + /** + * Property dirtyChanged + * @readonly + * + * @description + * Native form dirtiness used by guarded dismissal. + * + * @access public + * @since unreleased + * + * @type {OutputEmitterRef} + */ + public readonly dirtyChanged: OutputEmitterRef = output(); + /** + * Property targetQueryChanged + * @readonly + * + * @description + * Bounded readable directory query emitted to the owning page. + * + * @access public + * @since unreleased + * + * @type {OutputEmitterRef<{ + * readonly resourceType: MaintenanceExportResourceType; + * readonly page: number; + * readonly search: string; + * readonly archived?: boolean; + * }>} + */ + public readonly targetQueryChanged: OutputEmitterRef<{ + readonly resourceType: MaintenanceExportResourceType; + readonly page: number; + readonly search: string; + readonly archived?: boolean; + }> = output(); + /** + * Property mappingQueryChanged + * @readonly + * + * @description + * Exact mapping tuple whose latest revision must be read before submission. + * + * @access public + * @since unreleased + * + * @type {OutputEmitterRef<{ + * readonly resourceType: MaintenanceExportResourceType; + * readonly resourceId: string; + * readonly system: string; + * } | null>} + */ + public readonly mappingQueryChanged: OutputEmitterRef<{ + readonly resourceType: MaintenanceExportResourceType; + readonly resourceId: string; + readonly system: string; + } | null> = output(); + /** + * Property search + * @readonly + * + * @description + * Uncommitted search text, separate from the server query. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal} + */ + protected readonly search: WritableSignal = signal(''); + + /** + * Property archivedCustomers + * @readonly + * + * @description + * Explicit customer lifecycle scope for historical resource mappings. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal} + */ + protected readonly archivedCustomers: WritableSignal = signal(false); + /** + * Property draft + * @readonly + * + * @description + * Editable native form values; exact strings remain unchanged until validation. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal<{ + * resourceType: MaintenanceExportResourceType; + * resourceId: string; + * system: string; + * reference: string; + * }>} + */ + protected readonly draft: WritableSignal<{ + resourceType: MaintenanceExportResourceType; + resourceId: string; + system: string; + reference: string; + }> = signal({ resourceType: 'customer', resourceId: '', system: '', reference: '' }); + /** + * Property fields + * @readonly + * + * @description + * Signal Forms schema, validation and real field state. + * + * @access protected + * @since unreleased + * + * @type {FieldTree<{ + * resourceType: MaintenanceExportResourceType; + * resourceId: string; + * system: string; + * reference: string; + * }>} + */ + protected readonly fields: FieldTree<{ + resourceType: MaintenanceExportResourceType; + resourceId: string; + system: string; + reference: string; + }> = form(this.draft, (path) => { + disabled(path, () => this.locked() || this.pending()); + required(path.resourceId, { + message: $localize`:@@maintenanceExport.reference.targetRequired:Select an authorized resource.`, + }); + required(path.system, { + message: $localize`:@@maintenanceExport.form.systemRequired:Enter the external system code.`, + }); + maxLength(path.system, 40); + validate(path.system, ({ value }) => + /^[A-Za-z0-9][A-Za-z0-9_.-]{0,39}$/.test(value().trim()) + ? null + : { + kind: 'system', + message: $localize`:@@maintenanceExport.form.systemInvalid:Use letters, numbers, dots, underscores or hyphens (up to 40 characters).`, + }, + ); + required(path.reference, { + message: $localize`:@@maintenanceExport.reference.valueRequired:Enter the external resource reference.`, + }); + validate(path.reference, ({ value }) => + Array.from(value().trim()).length <= 200 + ? null + : { + kind: 'maxLength', + message: $localize`:@@maintenanceExport.form.limit200:Use no more than 200 characters.`, + }, + ); + }); + /** + * Property typeLabelOf + * @readonly + * + * @description + * Localized owner type labels for the native selector. + * + * @access protected + * @since unreleased + * + * @type {(value: unknown) => string} + */ + protected readonly typeLabelOf: (value: unknown) => string = (value) => { + const labels: Readonly> = { + customer: $localize`:@@maintenanceExport.reference.customer:Customer`, + site: $localize`:@@maintenanceExport.reference.site:Site`, + equipment: $localize`:@@maintenanceExport.reference.equipment:Equipment`, + }; + return typeof value === 'string' ? (labels[value] ?? '') : ''; + }; + /** + * Property targetLabelOf + * @readonly + * + * @description + * Readable selected owner identity, retained while another page is visible. + * + * @access protected + * @since unreleased + * + * @type {(value: unknown) => string} + */ + protected readonly targetLabelOf: (value: unknown) => string = (value) => + typeof value === 'string' + ? (this.targets().data?.member.find((target) => target.id === value)?.label ?? + this.selectedLabel) + : ''; + /** + * Property selectedLabel + * + * @description + * Last readable selected identity, preserving its native selector label. + * + * @access private + * @since unreleased + * + * @type {string} + */ + private selectedLabel: string = ''; + /** + * Property selectedType + * + * @description + * Owner type whose directory was most recently requested. + * + * @access private + * @since unreleased + * + * @type {MaintenanceExportResourceType | null} + */ + private selectedType: MaintenanceExportResourceType | null = null; + + //#endregion + + //#region Constructor + /** + * Constructor + * @constructor + * + * @description + * Connects native draft, authenticated scope and acknowledged command consequences. + * + * @access public + * @since unreleased + */ + public constructor() { + effect(() => this.dirtyChanged.emit(this.fields().dirty())); + effect(() => { + const allowed = this.allowedTypes(); + if (allowed.length && !allowed.includes(this.draft().resourceType)) + untracked(() => + this.draft.update((draft) => ({ + ...draft, + resourceType: allowed[0] ?? 'customer', + resourceId: '', + reference: '', + })), + ); + }); + effect(() => { + const type = this.fields.resourceType().value(); + if (this.selectedType === type) return; + this.selectedType = type; + untracked(() => { + this.draft.update((draft) => ({ ...draft, resourceId: '', reference: '' })); + this.selectedLabel = ''; + this.search.set(''); + this.archivedCustomers.set(false); + this.targetQueryChanged.emit({ + resourceType: type, + page: 1, + search: '', + ...(type === 'customer' ? { archived: false } : {}), + }); + }); + }); + effect(() => { + const system = this.fields.system().value().trim(), + resourceType = this.fields.resourceType().value(), + resourceId = this.fields.resourceId().value(); + untracked(() => { + if (resourceId) + this.selectedLabel = + this.targets().data?.member.find((item) => item.id === resourceId)?.label ?? + this.selectedLabel; + this.mappingQueryChanged.emit( + system && resourceId && !this.fields.system().invalid() + ? { system, resourceType, resourceId } + : null, + ); + }); + }); + effect(() => { + const mapping = this.mapping(); + if (mapping.status === 'success' && !this.fields.reference().dirty()) + untracked(() => + this.draft.update((draft) => ({ ...draft, reference: mapping.data?.reference ?? '' })), + ); + }); + } + + //#endregion + + //#region Methods + /** + * Method searchInput + * @method searchInput + * + * @description + * Retains uncommitted search text without issuing transport requests. + * + * @access protected + * @since unreleased + * + * @param {Event} event - Native control event. + * + * @returns {void} No return value. + */ + protected searchInput(event: Event): void { + if (event.target instanceof HTMLInputElement) this.search.set(event.target.value); + } + + /** + * Method searchTargets + * @method searchTargets + * + * @description + * Emits one bounded authorized directory query. + * + * @access protected + * @since unreleased + * + * @param {number} page - Explicit page supplied by the owning workflow. + * + * @returns {void} No return value. + */ + protected searchTargets(page: number = 1): void { + this.targetQueryChanged.emit({ + resourceType: this.draft().resourceType, + page, + search: this.search(), + ...(this.draft().resourceType === 'customer' ? { archived: this.archivedCustomers() } : {}), + }); + } + + /** + * Method archiveFilter + * @method archiveFilter + * + * @description + * Requests the selected customer directory scope while retaining the chosen mapping identity. + * + * @access protected + * @since unreleased + * + * @param {unknown} value - Native lifecycle choice. + * + * @returns {void} No return value. + */ + protected archiveFilter(value: unknown): void { + if (this.pending() || this.locked() || (value !== 'active' && value !== 'archived')) return; + this.archivedCustomers.set(value === 'archived'); + this.searchTargets(); + } + + /** + * Method submit + * @method submit + * + * @description + * Validates native field state and emits the exact accepted form intent. + * + * @access protected + * @since unreleased + * + * @param {Event} event - Native control event. + * + * @returns {void} No return value. + */ + protected submit(event: Event): void { + event.preventDefault(); + this.fields().markAsTouched(); + if ( + this.fields().invalid() || + this.fields().disabled() || + this.mapping().status !== 'success' || + !this.allowedTypes().includes(this.draft().resourceType) + ) + return; + const draft = this.draft(), + mapping = this.mapping().data; + this.submitted.emit({ + resourceType: draft.resourceType, + resourceId: draft.resourceId, + system: draft.system.trim(), + reference: draft.reference.trim(), + revision: mapping?.revision ?? 0, + }); + } + //#endregion +} diff --git a/src/app/features/organization/features/maintenance-exports/ui/forms/maintenance-export-reference-form/testing/maintenance-export-reference-form.component.spec.ts b/src/app/features/organization/features/maintenance-exports/ui/forms/maintenance-export-reference-form/testing/maintenance-export-reference-form.component.spec.ts new file mode 100644 index 000000000..2f93c0618 --- /dev/null +++ b/src/app/features/organization/features/maintenance-exports/ui/forms/maintenance-export-reference-form/testing/maintenance-export-reference-form.component.spec.ts @@ -0,0 +1,169 @@ +import { TestBed, type ComponentFixture } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { BrnSelect } from '@spartan-ng/brain/select'; +import { successCallState, errorCallState, toStoreError } from '@core/request-state'; +import { HlmSelect } from '@shared/ui/select'; +import { MaintenanceExportReferenceForm } from '../maintenance-export-reference-form.component'; + +describe('MaintenanceExportReferenceForm', () => { + let fixture: ComponentFixture; + let submissions: unknown[]; + const root = (): HTMLElement => fixture.nativeElement as HTMLElement; + const fill = async (id: string, value: string): Promise => { + const input = root().querySelector('#' + id); + if (!input) throw new Error('Required native input missing.'); + input.value = value; + input.dispatchEvent(new Event('input', { bubbles: true })); + await fixture.whenStable(); + }; + const chooseTarget = async (): Promise => { + const target = fixture.debugElement.queryAll(By.directive(HlmSelect))[1]; + if (!target) throw new Error('Required native selector missing.'); + target.injector.get>(BrnSelect).select('site-1'); + await fixture.whenStable(); + }; + const submit = async (): Promise => { + root() + .querySelector('form') + ?.dispatchEvent(new Event('submit', { cancelable: true })); + await fixture.whenStable(); + }; + beforeEach(async () => { + fixture = TestBed.createComponent(MaintenanceExportReferenceForm); + submissions = []; + fixture.componentRef.setInput('allowedTypes', ['site']); + fixture.componentRef.setInput( + 'targets', + successCallState({ member: [{ id: 'site-1', label: 'North fire site' }], totalItems: 1 }), + ); + fixture.componentRef.setInput('mapping', successCallState(null)); + fixture.componentInstance.submitted.subscribe((value) => submissions.push(value)); + await fixture.whenStable(); + }); + it('emits a readable selected resource at the confirmed absence revision zero', async () => { + await fill('maintenance-reference-system', 'ERP'); + await chooseTarget(); + await fill('maintenance-reference-value', 'ERP-SITE-1'); + await submit(); + expect(submissions).toEqual([ + { + resourceType: 'site', + resourceId: 'site-1', + system: 'ERP', + reference: 'ERP-SITE-1', + revision: 0, + }, + ]); + expect(root().querySelector('#maintenance-reference-target')?.textContent).toContain( + 'North fire site', + ); + }); + it('uses the server-reviewed mapping revision and preserves a dirty draft during a newer read', async () => { + await fill('maintenance-reference-system', 'ERP'); + await chooseTarget(); + await fill('maintenance-reference-value', 'MY-EDIT'); + fixture.componentRef.setInput( + 'mapping', + successCallState({ + id: 'mapping', + resourceType: 'site', + resourceId: 'site-1', + system: 'ERP', + reference: 'OTHER-EDIT', + revision: 4, + updatedAt: '2026-10-07T12:00:00Z', + }), + ); + await fixture.whenStable(); + expect(root().querySelector('#maintenance-reference-value')?.value).toBe( + 'MY-EDIT', + ); + await submit(); + expect(submissions[0]).toMatchObject({ revision: 4, reference: 'MY-EDIT' }); + }); + it('does not turn a failed current reference read into optimistic revision zero', async () => { + await fill('maintenance-reference-system', 'ERP'); + await chooseTarget(); + await fill('maintenance-reference-value', 'ERP-SITE-1'); + fixture.componentRef.setInput( + 'mapping', + errorCallState( + toStoreError({ + type: 'about:blank', + status: 403, + title: 'Denied', + detail: 'Mapping directory forbidden.', + }), + ), + ); + await fixture.whenStable(); + await submit(); + expect(submissions).toEqual([]); + }); + it('offers no raw identifier field when the owner directories are not readable', async () => { + fixture.componentRef.setInput('allowedTypes', []); + await fixture.whenStable(); + expect(root().querySelector('#maintenance-reference-target')).toBeNull(); + expect(root().textContent).toContain('directory permission is required'); + await submit(); + expect(submissions).toEqual([]); + }); + it('selects an archived customer and retains its UUID, label and mapping across directory scope changes', async () => { + const queries: unknown[] = []; + fixture.componentInstance.targetQueryChanged.subscribe((query) => queries.push(query)); + fixture.componentRef.setInput('allowedTypes', ['customer']); + fixture.componentRef.setInput('targets', successCallState({ member: [], totalItems: 0 })); + await fixture.whenStable(); + const archive = root().querySelector('button[value="archived"]'); + archive?.click(); + await fixture.whenStable(); + expect(queries.at(-1)).toEqual({ + resourceType: 'customer', + page: 1, + search: '', + archived: true, + }); + fixture.componentRef.setInput( + 'targets', + successCallState({ + member: [{ id: 'archived-customer', label: 'Archived fire operator' }], + totalItems: 1, + }), + ); + await fixture.whenStable(); + const target = fixture.debugElement.queryAll(By.directive(HlmSelect))[1]; + target?.injector.get>(BrnSelect).select('archived-customer'); + await fixture.whenStable(); + await fill('maintenance-reference-system', 'ERP'); + fixture.componentRef.setInput( + 'mapping', + successCallState({ + id: 'mapping', + resourceType: 'customer', + resourceId: 'archived-customer', + system: 'ERP', + reference: 'ERP-RETAINED', + revision: 3, + updatedAt: '2026-10-07T12:00:00Z', + }), + ); + await fixture.whenStable(); + await fill('maintenance-reference-value', 'ERP-REVISED'); + root().querySelector('button[value="active"]')?.click(); + fixture.componentRef.setInput('targets', successCallState({ member: [], totalItems: 0 })); + await fixture.whenStable(); + expect(root().querySelector('#maintenance-reference-target')?.textContent).toContain( + 'Archived fire operator', + ); + await submit(); + expect(submissions).toEqual([ + { + resourceType: 'customer', + resourceId: 'archived-customer', + system: 'ERP', + reference: 'ERP-REVISED', + revision: 3, + }, + ]); + }); +}); diff --git a/src/app/features/organization/features/maintenance-exports/ui/pages/maintenance-exports-page/maintenance-exports-page.component.html b/src/app/features/organization/features/maintenance-exports/ui/pages/maintenance-exports-page/maintenance-exports-page.component.html new file mode 100644 index 000000000..9c03a4c0f --- /dev/null +++ b/src/app/features/organization/features/maintenance-exports/ui/pages/maintenance-exports-page/maintenance-exports-page.component.html @@ -0,0 +1,483 @@ +
+
+
+

Prestation exports

+

+ Retain versioned CSV/JSON files and record actual ERP imports. Commercial invoicing remains + in your ERP. +

+
+
+ @if (canManage()) { + + } +
+
+ @if (store.detailCallState().status === 'pending') { +
+ } + @if (store.detailCallState().error; as error) { +
+

{{ error.message }}

+ +
+ } + @if (store.detailCallState().data; as archive) { +
+
+
+

{{ archive.system }} · {{ kindLabel(archive.kind) }}

+ {{ stateLabel(archive.state) }} +
+

+ {{ archive.createdAt | appOrgDate: 'datetime' : regionalFormatting() }} +

+
+
+
+
+
+ Schema version +
+
{{ archive.schemaVersion }}
+
+
+
+ Retained prestation rows +
+
{{ archive.rowCount }}
+
+
+
+ Published dossiers +
+
{{ archive.sourceInterventionIds.length }}
+
+
+ @if (archive.adjustmentOf) { +
+ Open corrected export + @if (archive.originalExportId) { + Open original export + } +
+ } + @if (archive.reason) { +
+

+ Retained adjustment reason +

+

{{ archive.reason }}

+
+ } + @if (archive.confirmation; as confirmation) { +
+

+ External import confirmed +

+

+ {{ confirmation.externalImportReference }} · + {{ confirmation.confirmedAt | appOrgDate: 'datetime' : regionalFormatting() }} +

+
+ } @else { +
+

+ The file has been generated. No external import has been confirmed. +

+
+ } + @if (archive.includeInternalCosts) { +

+ Private archive including internal costs. +

+ @if (archive.costsComplete === false) { +
+

+ Internal costs are incomplete. Unknown amounts are retained explicitly. +

+
+ } + } +
+
+

JSON

+

{{ archive.files.json.size }} bytes

+

{{ archive.files.json.sha256 }}

+
+
+

CSV

+

{{ archive.files.csv.size }} bytes

+

{{ archive.files.csv.sha256 }}

+
+
+ @if (store.downloadCallState().error; as error) { +
+

{{ error.message }}

+
+ } +
+
+ + @if (canManage()) { + + } + @if (canConfirm() && !archive.confirmation) { + + } +
+
+ } +
+
+

Retained archives

+ +
+ @if (store.listCallState().status === 'pending') { +
+ } + @if (store.listCallState().error; as error) { +
+

{{ error.message }}

+
+ } + @if (store.listCallState().status === 'success') { + @if (store.exportEntities().length === 0) { +
+

No retained export yet

+

+ Generate an export from ready published maintenance dossiers. +

+
+ } @else { +
+ + + + + + + + + + + @for (archive of store.exportEntities(); track archive.id) { + + + + + + + } + +
System and versionExternal import stateCreatedRows
+ {{ archive.system }} · {{ kindLabel(archive.kind) }} · v{{ + archive.schemaVersion + }} + @if (archive.includeInternalCosts) { + Private costs + } + + {{ stateLabel(archive.state) }} + + {{ archive.createdAt | appOrgDate: 'datetime' : regionalFormatting() }} + {{ archive.rowCount }}
+
+ } +
+ Page {{ store.page() }} of {{ store.pageCount() }} +
+ } +
+
+

+ Current external references +

+ @if (store.referencesCallState().error; as error) { +
+

{{ error.message }}

+
+ } + @if (store.referencesCallState().data; as references) { +
+ @for (reference of references.member; track reference.id) { +
+

+ {{ reference.system }} · {{ reference.reference }} +

+

+ {{ typeLabel(reference.resourceType) }} · {{ reference.revision }} +

+
+ } +
+ @if (references.totalItems > 30) { +

+ Additional mappings are available through the scoped resource selector. +

+ } + } +
+
+ +

{{ editorTitle() }}

+

+ Operations retain their identity across retries. Review a confirmed conflict before + submitting a new command. +

+
+ @if (store.uncertainWrite()) { +
+

+ The response is uncertain. Recover this same operation before changing the draft. +

+ +
+ } + @if (conflict() && !store.uncertainWrite()) { +
+

+ The source changed or conflicts with this command. Your draft is retained. +

+
+ +
+
+ } + @if (creating()) { + + } @else if (referencing()) { + + } @else if (editor()) { + + } + @if (discardRequested()) { +
+

+ Discard the local draft and close this sheet? +

+
+ +
+
+ } +
diff --git a/src/app/features/organization/features/maintenance-exports/ui/pages/maintenance-exports-page/maintenance-exports-page.component.ts b/src/app/features/organization/features/maintenance-exports/ui/pages/maintenance-exports-page/maintenance-exports-page.component.ts new file mode 100644 index 000000000..0fa293815 --- /dev/null +++ b/src/app/features/organization/features/maintenance-exports/ui/pages/maintenance-exports-page/maintenance-exports-page.component.ts @@ -0,0 +1,1158 @@ +import { isPlatformBrowser } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + inject, + input, + PLATFORM_ID, + signal, + untracked, + type InputSignal, + type Signal, + type WritableSignal, +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { ActivatedRoute, Router, RouterLink } from '@angular/router'; +import { Events } from '@ngrx/signals/events'; +import { ConnectivityService } from '@core/connectivity'; +import type { StoreError } from '@core/request-state'; +import { AUTH_SESSION_PORT, type AuthSessionPort } from '@features/auth/ports'; +import { OrganizationPermissionService } from '@features/organization/access'; +import type { + CreateMaintenanceExportInput, + MaintenanceExportOutput, + MaintenanceExportResourceType, +} from '@features/organization/features/maintenance-exports/models'; +import { + MaintenanceExportStore, + maintenanceExportStoreEvents, + type MaintenanceExportStoreType, +} from '@features/organization/features/maintenance-exports/state'; +import { ORGANIZATION_PERMISSION } from '@features/organization/models'; +import { REGIONAL_FORMATTING_PORT } from '@features/organization/ports'; +import { BrowserDownloadService } from '@features/organization/services/browser-download'; +import { OrgDatePipe, type RegionalFormatSettings } from '@shared/regional-format'; +import { HlmAlertImports } from '@shared/ui/alert'; +import { HlmBadge } from '@shared/ui/badge'; +import { HlmButton } from '@shared/ui/button'; +import { HlmCardImports } from '@shared/ui/card'; +import { HlmEmptyImports } from '@shared/ui/empty'; +import { HlmSheetImports } from '@shared/ui/sheet'; +import { HlmSkeleton } from '@shared/ui/skeleton'; +import { HlmTableImports } from '@shared/ui/table'; +import { HlmLarge } from '@shared/ui/typography'; +import { MaintenanceExportActionForm } from '../../forms/maintenance-export-action-form'; +import { MaintenanceExportCreateForm } from '../../forms/maintenance-export-create-form'; +import { MaintenanceExportReferenceForm } from '../../forms/maintenance-export-reference-form'; + +/** + * Class MaintenanceExportsPage + * @class MaintenanceExportsPage + * + * @description + * Orchestrates browser-private archives, native command sheets and explicit external import + * acknowledgement. + */ +@Component({ + selector: 'app-maintenance-exports-page', + templateUrl: './maintenance-exports-page.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [MaintenanceExportStore], + imports: [ + RouterLink, + OrgDatePipe, + HlmButton, + HlmBadge, + HlmLarge, + HlmSkeleton, + MaintenanceExportCreateForm, + MaintenanceExportActionForm, + MaintenanceExportReferenceForm, + ...HlmAlertImports, + ...HlmCardImports, + ...HlmEmptyImports, + ...HlmSheetImports, + ...HlmTableImports, + ], +}) +export class MaintenanceExportsPage { + //#region Properties + /** + * Property router + * @readonly + * + * @description + * Application router that keeps acknowledged archive identity in the URL. + * + * @access private + * @since unreleased + * + * @type {Router} + */ + private readonly router: Router = inject(Router); + /** + * Property route + * @readonly + * + * @description + * Current organization route used for archive query navigation. + * + * @access private + * @since unreleased + * + * @type {ActivatedRoute} + */ + private readonly route: ActivatedRoute = inject(ActivatedRoute); + /** + * Property deactivationResolver + * + * @description + * Pending guarded navigation decision, resolved exactly once. + * + * @access private + * @since unreleased + * + * @type {((allowed: boolean) => void) | null} + */ + private deactivationResolver: ((allowed: boolean) => void) | null = null; + /** + * Property organizationId + * @readonly + * + * @description + * Owning organization boundary for every read and accepted command. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly organizationId: InputSignal = input.required(); + /** + * Property exportId + * @readonly + * + * @description + * Optional selected archive supplied by the route query. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly exportId: InputSignal = input(); + /** + * Property store + * @readonly + * + * @description + * Route-scoped archive state and command acceptance authority. + * + * @access protected + * @since unreleased + * + * @type {MaintenanceExportStoreType} + */ + protected readonly store: MaintenanceExportStoreType = inject(MaintenanceExportStore); + /** + * Property regionalFormatting + * @readonly + * + * @description + * Organization date and timezone formatting preferences. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly regionalFormatting: Signal = + inject(REGIONAL_FORMATTING_PORT).regionalFormatting; + /** + * Property permissions + * @readonly + * + * @description + * Effective server-owned organization permission grants. + * + * @access private + * @since unreleased + * + * @type {OrganizationPermissionService} + */ + private readonly permissions: OrganizationPermissionService = inject( + OrganizationPermissionService, + ); + /** + * Property session + * @readonly + * + * @description + * Authenticated session boundary that invalidates private archive data. + * + * @access private + * @since unreleased + * + * @type {AuthSessionPort} + */ + private readonly session: AuthSessionPort = inject(AUTH_SESSION_PORT); + /** + * Property platform + * @readonly + * + * @description + * Rendering platform used to prohibit private SSR requests. + * + * @access private + * @since unreleased + * + * @type {object} + */ + private readonly platform: object = inject(PLATFORM_ID); + /** + * Property downloader + * @readonly + * + * @description + * Owner browser helper that saves the exact retained bytes. + * + * @access private + * @since unreleased + * + * @type {BrowserDownloadService} + */ + private readonly downloader: BrowserDownloadService = inject(BrowserDownloadService); + /** + * Property connectivity + * @readonly + * + * @description + * Connectivity authority used to defer new archive writes while offline. + * + * @access private + * @since unreleased + * + * @type {ConnectivityService} + */ + private readonly connectivity: ConnectivityService = inject(ConnectivityService); + /** + * Property editor + * @readonly + * + * @description + * Explicit native command sheet currently selected by the reader. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal<'create' | 'adjust' | 'confirm' | 'reference' | null>} + */ + protected readonly editor: WritableSignal<'create' | 'adjust' | 'confirm' | 'reference' | null> = + signal(null); + /** + * Property sourcePage + * @readonly + * + * @description + * Current published dossier selector page. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal} + */ + protected readonly sourcePage: WritableSignal = signal(1); + /** + * Property sourceSearch + * @readonly + * + * @description + * Committed server search for published dossier choices. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal} + */ + protected readonly sourceSearch: WritableSignal = signal(''); + /** + * Property targetPage + * @readonly + * + * @description + * Current resource directory page. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal} + */ + protected readonly targetPage: WritableSignal = signal(1); + /** + * Property dirty + * @readonly + * + * @description + * Native form dirtiness reported by the active editor. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal} + */ + protected readonly dirty: WritableSignal = signal(false); + /** + * Property discardRequested + * @readonly + * + * @description + * Whether the reader must resolve dismissal of a modified draft. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal} + */ + protected readonly discardRequested: WritableSignal = signal(false); + /** + * Property reviewRequested + * @readonly + * + * @description + * Whether a confirmed conflict source has been explicitly reloaded. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal} + */ + protected readonly reviewRequested: WritableSignal = signal(false); + /** + * Property canManage + * @readonly + * + * @description + * Separate archive and mapping management permission. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly canManage: Signal = computed(() => + this.permissions.hasPermission(ORGANIZATION_PERMISSION.MAINTENANCE_EXPORTS_MANAGE), + ); + /** + * Property canConfirm + * @readonly + * + * @description + * Separate permission to acknowledge an actual external import. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly canConfirm: Signal = computed(() => + this.permissions.hasPermission(ORGANIZATION_PERMISSION.MAINTENANCE_EXPORTS_CONFIRM), + ); + /** + * Property canReadCosts + * @readonly + * + * @description + * Independent financial read permission for private exports. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly canReadCosts: Signal = computed(() => + this.permissions.hasPermission(ORGANIZATION_PERMISSION.MAINTENANCE_COST_READ), + ); + /** + * Property allowedReferenceTypes + * @readonly + * + * @description + * Readable owner types available for external mapping selection. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly allowedReferenceTypes: Signal = + computed(() => { + const result: MaintenanceExportResourceType[] = []; + if (this.permissions.hasPermission(ORGANIZATION_PERMISSION.CUSTOMERS_READ)) + result.push('customer'); + if (this.permissions.hasPermission(ORGANIZATION_PERMISSION.FACILITIES_READ)) + result.push('site'); + if (this.permissions.hasPermission(ORGANIZATION_PERMISSION.EQUIPMENT_READ)) + result.push('equipment'); + return result; + }); + /** + * Property pending + * @readonly + * + * @description + * Whether an accepted command is waiting for its acknowledgement. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly pending: Signal = computed(() => this.store.writePending()); + /** + * Property locked + * @readonly + * + * @description + * Whether editing is prohibited by permissions, connectivity or receipt recovery. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly locked: Signal = computed( + () => this.store.writePending() || this.store.uncertainWrite() || !this.connectivity.isOnline(), + ); + /** + * Property conflict + * @readonly + * + * @description + * Confirmed concurrency or identity conflict requiring explicit source review. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly conflict: Signal = computed(() => + ['409', '412'].includes(String(this.store.writeCallState().error?.code ?? '')), + ); + /** + * Property creating + * @readonly + * + * @description + * Whether the creation form is mounted in the native sheet. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly creating: Signal = computed(() => this.editor() === 'create'); + /** + * Property referencing + * @readonly + * + * @description + * Whether the external mapping form is mounted. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly referencing: Signal = computed(() => this.editor() === 'reference'); + /** + * Property confirming + * @readonly + * + * @description + * Whether the actual import acknowledgement form is mounted. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly confirming: Signal = computed(() => this.editor() === 'confirm'); + /** + * Property editorTitle + * @readonly + * + * @description + * Localized accessible title for the active native sheet. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly editorTitle: Signal = computed(() => { + const labels: Readonly> = { + create: $localize`:@@maintenanceExport.editor.create:Generate a prestation export`, + adjust: $localize`:@@maintenanceExport.editor.adjust:Append an adjustment`, + confirm: $localize`:@@maintenanceExport.editor.confirm:Confirm an external import`, + reference: $localize`:@@maintenanceExport.editor.reference:Map an external resource reference`, + }; + const editor = this.editor(); + return editor ? (labels[editor] ?? '') : ''; + }); + /** + * Property writeError + * @readonly + * + * @description + * Server rejection retained beside the unchanged command draft. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly writeError: Signal = computed( + () => this.store.writeCallState().error, + ); + /** + * Property downloadFilename + * + * @description + * Selected retained archive filename used after the byte request succeeds. + * + * @access private + * @since unreleased + * + * @type {string} + */ + private downloadFilename: string = ''; + /** + * Property renderedScope + * + * @description + * Organization, session and financial access identity already rendered. + * + * @access private + * @since unreleased + * + * @type {string} + */ + private renderedScope: string = ''; + /** + * Property mappingQuery + * + * @description + * Exact readable mapping tuple retained for explicit conflict review. + * + * @access private + * @since unreleased + * + * @type {{ + * readonly resourceType: MaintenanceExportResourceType; + * readonly resourceId: string; + * readonly system: string; + * } | null} + */ + private mappingQuery: { + readonly resourceType: MaintenanceExportResourceType; + readonly resourceId: string; + readonly system: string; + } | null = null; + + //#endregion + + //#region Constructor + /** + * Constructor + * @constructor + * + * @description + * Connects native draft, authenticated scope and acknowledged command consequences. + * + * @access public + * @since unreleased + */ + public constructor() { + effect(() => { + const organizationId = this.organizationId(), + revision = this.session.sessionRevision(), + authenticated = this.session.isAuthenticated(), + allowed = this.permissions.hasPermission(ORGANIZATION_PERMISSION.MAINTENANCE_EXPORTS_READ); + if (!isPlatformBrowser(this.platform) || !authenticated || !allowed) { + untracked(() => { + this.renderedScope = ''; + this.store.setScope(null); + this.editor.set(null); + }); + return; + } + const identity = organizationId + '/' + revision + '/' + this.canReadCosts(); + untracked(() => { + if (identity !== this.renderedScope) { + this.renderedScope = identity; + this.editor.set(null); + this.dirty.set(false); + this.mappingQuery = null; + this.store.setScope({ organizationId, sessionRevision: revision }); + this.store.load({ page: 1 }); + this.store.loadReferences({ page: 1 }); + } + }); + }); + effect(() => { + const id = this.exportId(); + this.store.scope(); + untracked(() => this.store.read(id ?? null)); + }); + effect(() => { + const downloaded = this.store.downloadCallState(); + const blob = downloaded.data; + if (downloaded.status === 'success' && blob && isPlatformBrowser(this.platform)) + untracked(() => { + this.downloader.trigger(blob, this.downloadFilename); + this.store.clearDownload(); + }); + }); + inject(Events) + .on(maintenanceExportStoreEvents.acknowledged) + .pipe(takeUntilDestroyed()) + .subscribe(({ payload }) => { + if ( + payload.organizationId !== this.organizationId() || + payload.sessionRevision !== this.session.sessionRevision() + ) + return; + this.editor.set(null); + this.dirty.set(false); + this.reviewRequested.set(false); + this.store.load({ page: this.store.page() }); + this.store.loadReferences({ page: 1 }); + if (payload.kind !== 'reference') + void this.router.navigate([], { + relativeTo: this.route, + queryParams: { exportId: payload.id }, + queryParamsHandling: 'merge', + }); + }); + } + + //#endregion + + //#region Methods + /** + * Method stateLabel + * @method stateLabel + * + * @description + * Separates generated files from actual acknowledged external imports. + * + * @access protected + * @since unreleased + * + * @param {string} value - Explicit value supplied by the owning workflow. + * + * @returns {string} Result for the current authorized archive workflow. + */ + protected stateLabel(value: string): string { + const labels: Readonly> = { + generated: $localize`:@@maintenanceExport.state.generated:Export generated`, + import_confirmed: $localize`:@@maintenanceExport.state.confirmed:Import confirmed`, + }; + return labels[value] ?? value; + } + + /** + * Method kindLabel + * @method kindLabel + * + * @description + * Identifies an original archive or its linked adjustment. + * + * @access protected + * @since unreleased + * + * @param {string} value - Explicit value supplied by the owning workflow. + * + * @returns {string} Result for the current authorized archive workflow. + */ + protected kindLabel(value: string): string { + const labels: Readonly> = { + initial: $localize`:@@maintenanceExport.kind.initial:Initial export`, + adjustment: $localize`:@@maintenanceExport.kind.adjustment:Adjustment`, + }; + return labels[value] ?? value; + } + + /** + * Method typeLabel + * @method typeLabel + * + * @description + * Localizes the supported owner type without changing server codes. + * + * @access protected + * @since unreleased + * + * @param {string} value - Explicit value supplied by the owning workflow. + * + * @returns {string} Result for the current authorized archive workflow. + */ + protected typeLabel(value: string): string { + const labels: Readonly> = { + customer: $localize`:@@maintenanceExport.reference.customer:Customer`, + site: $localize`:@@maintenanceExport.reference.site:Site`, + equipment: $localize`:@@maintenanceExport.reference.equipment:Equipment`, + }; + return labels[value] ?? value; + } + + /** + * Method open + * @method open + * + * @description + * Opens an explicitly permitted native editor and starts only its necessary secondary reads. + * + * @access protected + * @since unreleased + * + * @param {'create' | 'adjust' | 'confirm' | 'reference'} kind - Explicit kind supplied by the + * owning workflow. + * + * @returns {void} No return value. + */ + protected open(kind: 'create' | 'adjust' | 'confirm' | 'reference'): void { + if (this.locked() || (kind === 'confirm' ? !this.canConfirm() : !this.canManage())) return; + const detail = this.store.detailCallState().data; + if ((kind === 'adjust' || kind === 'confirm') && (!detail || !this.readableArchive(detail))) + return; + this.store.clearCommand(); + this.reviewRequested.set(false); + this.dirty.set(false); + this.discardRequested.set(false); + this.editor.set(kind); + if (kind === 'create') { + this.sourcePage.set(1); + this.sourceSearch.set(''); + this.store.loadSources({ page: 1 }); + } + } + + /** + * Method sourcesChanged + * @method sourcesChanged + * + * @description + * Loads a committed server selector page while the form keeps selected dossiers. + * + * @access protected + * @since unreleased + * + * @param {{ readonly page: number; readonly search?: string }} query - Explicit query supplied by + * the owning workflow. + * + * @returns {void} No return value. + */ + protected sourcesChanged(query: { readonly page: number; readonly search?: string }): void { + this.sourcePage.set(query.page); + if (query.search !== undefined) this.sourceSearch.set(query.search); + this.store.loadSources({ page: query.page, search: this.sourceSearch() }); + } + + /** + * Method targetsChanged + * @method targetsChanged + * + * @description + * Loads a bounded readable owner directory page. + * + * @access protected + * @since unreleased + * + * @param {{ + * readonly resourceType: MaintenanceExportResourceType; + * readonly page: number; + * readonly search: string; + * readonly archived?: boolean; + * }} query + * - Explicit query supplied by the owning workflow. + * + * @returns {void} No return value. + */ + protected targetsChanged(query: { + readonly resourceType: MaintenanceExportResourceType; + readonly page: number; + readonly search: string; + readonly archived?: boolean; + }): void { + this.targetPage.set(query.page); + this.store.loadTargets(query); + } + + /** + * Method mappingChanged + * @method mappingChanged + * + * @description + * Reads the exact mapping tuple before granting revision-zero or current-revision authority. + * + * @access protected + * @since unreleased + * + * @param {{ + * readonly resourceType: MaintenanceExportResourceType; + * readonly resourceId: string; + * readonly system: string; + * } | null} query + * - Explicit query supplied by the owning workflow. + * + * @returns {void} No return value. + */ + protected mappingChanged( + query: { + readonly resourceType: MaintenanceExportResourceType; + readonly resourceId: string; + readonly system: string; + } | null, + ): void { + this.mappingQuery = query; + this.store.readMapping(query); + } + + /** + * Method create + * @method create + * + * @description + * Accepts the validated bounded dossier selection with one stable operation identity. + * + * @access protected + * @since unreleased + * + * @param {Omit} commandInput - Explicit + * commandInput supplied by the owning workflow. + * + * @returns {void} No return value. + */ + protected create(commandInput: Omit): void { + if (!this.locked() && this.canManage()) + this.store.write({ + kind: 'create', + organizationId: this.organizationId(), + input: { ...commandInput, clientOperationId: crypto.randomUUID() }, + }); + } + + /** + * Method act + * @method act + * + * @description + * Accepts a motivated adjustment or actual import reference at the displayed source revision. + * + * @access protected + * @since unreleased + * + * @param {string} value - Explicit value supplied by the owning workflow. + * + * @returns {void} No return value. + */ + protected act(value: string): void { + const detail = this.store.detailCallState().data; + if (!detail || this.locked() || this.conflict()) return; + if (this.confirming() && this.canConfirm()) + this.store.write({ + kind: 'confirm', + organizationId: this.organizationId(), + exportId: detail.id, + revision: detail.revision, + input: { clientOperationId: crypto.randomUUID(), externalImportReference: value }, + }); + else if (this.canManage()) + this.store.write({ + kind: 'adjust', + organizationId: this.organizationId(), + exportId: detail.id, + revision: detail.revision, + input: { clientOperationId: crypto.randomUUID(), reason: value }, + }); + } + + /** + * Method saveReference + * @method saveReference + * + * @description + * Accepts a readable mapping choice with its reviewed optimistic revision. + * + * @access protected + * @since unreleased + * + * @param {{ + * readonly resourceType: MaintenanceExportResourceType; + * readonly resourceId: string; + * readonly system: string; + * readonly reference: string; + * readonly revision: number; + * }} commandInput + * - Explicit commandInput supplied by the owning workflow. + * + * @returns {void} No return value. + */ + protected saveReference(commandInput: { + readonly resourceType: MaintenanceExportResourceType; + readonly resourceId: string; + readonly system: string; + readonly reference: string; + readonly revision: number; + }): void { + if (this.locked() || this.conflict() || !this.canManage()) return; + this.store.write({ + kind: 'reference', + organizationId: this.organizationId(), + resourceType: commandInput.resourceType, + resourceId: commandInput.resourceId, + revision: commandInput.revision, + input: { + clientOperationId: crypto.randomUUID(), + system: commandInput.system, + reference: commandInput.reference, + }, + }); + } + + /** + * Method retry + * @method retry + * + * @description + * Recovers the original uncertain operation with the same body, UUID and revision. + * + * @access protected + * @since unreleased + * + * @returns {void} No return value. + */ + protected retry(): void { + const command = this.store.command(); + if (command && !this.store.writePending()) this.store.write(command); + } + + /** + * Method loadReview + * @method loadReview + * + * @description + * Explicitly reloads a confirmed conflicting source while preserving the local form. + * + * @access protected + * @since unreleased + * + * @returns {void} No return value. + */ + protected loadReview(): void { + if (this.locked()) return; + this.reviewRequested.set(true); + if (this.referencing()) this.store.readMapping(this.mappingQuery); + else if (this.store.selectedId()) this.store.read(this.store.selectedId()); + } + + /** + * Method reviewReady + * @method reviewReady + * + * @description + * Requires a successful completed source read before adopting a newer revision. + * + * @access protected + * @since unreleased + * + * @returns {boolean} Result for the current authorized archive workflow. + */ + protected reviewReady(): boolean { + return ( + this.reviewRequested() && + (this.referencing() + ? this.store.mappingCallState().status === 'success' + : this.store.detailCallState().status === 'success') + ); + } + + /** + * Method adoptReview + * @method adoptReview + * + * @description + * Adopts the explicitly reviewed source without clearing the user draft. + * + * @access protected + * @since unreleased + * + * @returns {void} No return value. + */ + protected adoptReview(): void { + if (!this.reviewReady()) return; + this.store.clearCommand(); + this.reviewRequested.set(false); + } + + /** + * Method readableArchive + * @method readableArchive + * + * @description + * Applies independent financial access to archives that include internal costs. + * + * @access protected + * @since unreleased + * + * @param {MaintenanceExportOutput} archive - Explicit archive supplied by the owning workflow. + * + * @returns {boolean} Result for the current authorized archive workflow. + */ + protected readableArchive(archive: MaintenanceExportOutput): boolean { + return !archive.includeInternalCosts || this.canReadCosts(); + } + + /** + * Method download + * @method download + * + * @description + * Requests retained bytes and preserves their server-owned schema and identity. + * + * @access protected + * @since unreleased + * + * @param {MaintenanceExportOutput} archive - Explicit archive supplied by the owning workflow. + * @param {'json' | 'csv'} format - Explicit format supplied by the owning workflow. + * + * @returns {void} No return value. + */ + protected download(archive: MaintenanceExportOutput, format: 'json' | 'csv'): void { + if (!this.readableArchive(archive) || this.store.downloadCallState().status === 'pending') + return; + this.downloadFilename = + 'fireguard-prestations-' + archive.id + '-v' + archive.schemaVersion + '.' + format; + this.store.download({ + exportId: archive.id, + format, + includeInternalCosts: archive.includeInternalCosts, + }); + } + + /** + * Method close + * @method close + * + * @description + * Controls draft dismissal and prohibits closing an accepted uncertain command. + * + * @access protected + * @since unreleased + * + * @returns {void} No return value. + */ + protected close(): void { + if (this.locked()) return; + if (this.dirty()) this.discardRequested.set(true); + else this.editor.set(null); + } + + /** + * Method stateChanged + * @method stateChanged + * + * @description + * Routes native sheet dismissal through the same guarded draft decision. + * + * @access protected + * @since unreleased + * + * @param {string} state - Explicit state supplied by the owning workflow. + * + * @returns {void} No return value. + */ + protected stateChanged(state: string): void { + if (state === 'closed' && this.editor()) this.close(); + } + + /** + * Method discard + * @method discard + * + * @description + * Resolves confirmed draft abandonment and any pending navigation decision. + * + * @access protected + * @since unreleased + * + * @returns {void} No return value. + */ + protected discard(): void { + if (!this.locked()) { + this.dirty.set(false); + this.discardRequested.set(false); + this.editor.set(null); + this.deactivationResolver?.(true); + this.deactivationResolver = null; + } + } + + /** + * Method keepEditing + * @method keepEditing + * + * @description + * Retains the modified draft and rejects pending navigation. + * + * @access protected + * @since unreleased + * + * @returns {void} No return value. + */ + protected keepEditing(): void { + this.discardRequested.set(false); + this.deactivationResolver?.(false); + this.deactivationResolver = null; + } + + /** + * Method hasUnsavedChanges + * @method hasUnsavedChanges + * + * @description + * Reports native drafts and unresolved accepted writes to the navigation guard. + * + * @access public + * @since unreleased + * + * @returns {boolean} Result for the current authorized archive workflow. + */ + public hasUnsavedChanges(): boolean { + return this.dirty() || this.store.writePending() || this.store.uncertainWrite(); + } + + /** + * Method confirmDeactivation + * @method confirmDeactivation + * + * @description + * Blocks unresolved writes and asks the reader to resolve a modified draft. + * + * @access public + * @since unreleased + * + * @returns {Promise} Result for the current authorized archive workflow. + */ + public confirmDeactivation(): Promise { + if (this.store.writePending() || this.store.uncertainWrite()) return Promise.resolve(false); + this.discardRequested.set(true); + return new Promise((resolve) => { + this.deactivationResolver = resolve; + }); + } + //#endregion +} diff --git a/src/app/features/organization/features/maintenance-exports/ui/pages/maintenance-exports-page/testing/maintenance-exports-page.component.spec.ts b/src/app/features/organization/features/maintenance-exports/ui/pages/maintenance-exports-page/testing/maintenance-exports-page.component.spec.ts new file mode 100644 index 000000000..a4f0375f2 --- /dev/null +++ b/src/app/features/organization/features/maintenance-exports/ui/pages/maintenance-exports-page/testing/maintenance-exports-page.component.spec.ts @@ -0,0 +1,146 @@ +import { PLATFORM_ID, signal } from '@angular/core'; +import { TestBed, type ComponentFixture } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import { of } from 'rxjs'; +import { ConnectivityService } from '@core/connectivity'; +import { THEME_PORT } from '@core/theme'; +import { AUTH_SESSION_PORT } from '@features/auth/ports'; +import { OrganizationPermissionService } from '@features/organization/access'; +import { CustomerService } from '@features/organization/features/customers/data-access'; +import { EquipmentService } from '@features/organization/features/equipments/data-access'; +import { FacilityService } from '@features/organization/features/facilities/data-access'; +import { MaintenanceExportService } from '@features/organization/features/maintenance-exports/data-access'; +import { maintenanceExportFixture } from '@features/organization/features/maintenance-exports/models/export/testing/maintenance-export.fixture'; +import { ORGANIZATION_PERMISSION } from '@features/organization/models'; +import { REGIONAL_FORMATTING_PORT } from '@features/organization/ports'; +import { BrowserDownloadService } from '@features/organization/services/browser-download'; +import { MaintenanceExportsPage } from '../maintenance-exports-page.component'; + +describe('MaintenanceExportsPage', () => { + let fixture: ComponentFixture, + api: Record>; + const authenticated = signal(true), + sessionRevision = signal(1), + online = signal(true), + grants = signal([]); + const archive = maintenanceExportFixture(); + const root = (): HTMLElement => fixture.nativeElement as HTMLElement; + const setup = async (platform = 'browser'): Promise => { + TestBed.configureTestingModule({ + imports: [MaintenanceExportsPage], + providers: [ + provideRouter([]), + { provide: PLATFORM_ID, useValue: platform }, + { provide: MaintenanceExportService, useValue: api }, + { provide: CustomerService, useValue: { list: vi.fn() } }, + { provide: FacilityService, useValue: { list: vi.fn() } }, + { provide: EquipmentService, useValue: { list: vi.fn() } }, + { + provide: OrganizationPermissionService, + useValue: { hasPermission: (permission: string) => grants().includes(permission) }, + }, + { + provide: AUTH_SESSION_PORT, + useValue: { isAuthenticated: authenticated, sessionRevision }, + }, + { provide: ConnectivityService, useValue: { isOnline: online } }, + { provide: THEME_PORT, useValue: { resolvedTheme: signal('light') } }, + { + provide: REGIONAL_FORMATTING_PORT, + useValue: { regionalFormatting: signal({ dateFormat: 'dd/MM/yyyy', timezone: 'UTC' }) }, + }, + { provide: BrowserDownloadService, useValue: { trigger: vi.fn() } }, + ], + }); + fixture = TestBed.createComponent(MaintenanceExportsPage); + fixture.componentRef.setInput('organizationId', 'org'); + fixture.componentRef.setInput('exportId', archive.id); + await fixture.whenStable(); + }; + beforeAll(() => { + globalThis.ResizeObserver ??= class { + observe(): void {} + unobserve(): void {} + disconnect(): void {} + } as unknown as typeof ResizeObserver; + }); + beforeEach(() => { + authenticated.set(true); + sessionRevision.set(1); + online.set(true); + grants.set([ORGANIZATION_PERMISSION.MAINTENANCE_EXPORTS_READ]); + api = { + list: vi.fn().mockReturnValue(of({ member: [archive], totalItems: 1 })), + read: vi.fn().mockReturnValue(of(archive)), + listReferences: vi.fn().mockReturnValue(of({ member: [], totalItems: 0 })), + listSources: vi.fn().mockReturnValue(of({ member: [], totalItems: 0 })), + download: vi.fn().mockReturnValue(of(new Blob(['retained']))), + }; + }); + it.each(['server', 'anonymous', 'no-read'])( + 'performs zero private calls and renders no archive identity in %s', + async (mode) => { + if (mode === 'anonymous') authenticated.set(false); + if (mode === 'no-read') grants.set([]); + await setup(mode === 'server' ? 'server' : 'browser'); + expect(api['list']).not.toHaveBeenCalled(); + expect(api['read']).not.toHaveBeenCalled(); + expect(api['listReferences']).not.toHaveBeenCalled(); + expect(root().querySelector('[data-testid=maintenance-export-detail]')).toBeNull(); + }, + ); + it('distinguishes a generated archive from an actually confirmed import in a read-only workspace', async () => { + await setup(); + expect(root().textContent).toContain('No external import has been confirmed'); + expect(root().querySelector('[data-testid=maintenance-export-create]')).toBeNull(); + expect(root().textContent).toContain('Download retained JSON'); + expect(root().textContent).not.toContain('Confirm external import'); + }); + it('shows the actual import reference and keeps adjustments linked to their predecessor', async () => { + api['read']?.mockReturnValue( + of( + maintenanceExportFixture({ + kind: 'adjustment', + adjustmentOf: 'original', + originalExportId: 'original', + state: 'import_confirmed', + confirmation: { + clientOperationId: 'ack', + externalImportReference: 'ERP-IMPORT-42', + confirmedAt: '2026-10-07T12:01:00Z', + actorId: 'member', + }, + }), + ), + ); + await setup(); + expect(root().textContent).toContain('ERP-IMPORT-42'); + expect(root().textContent).toContain('Open corrected export'); + expect(root().textContent).not.toContain('No external import has been confirmed'); + }); + it('does not make source picker reads until the creation sheet is explicitly opened', async () => { + grants.set([...grants(), ORGANIZATION_PERMISSION.MAINTENANCE_EXPORTS_MANAGE]); + await setup(); + expect(api['listSources']).not.toHaveBeenCalled(); + root().querySelector('[data-testid=maintenance-export-create]')?.click(); + await fixture.whenStable(); + expect(api['listSources']).toHaveBeenCalledTimes(1); + expect(document.body.textContent).toContain('Generate a prestation export'); + }); + it('clears archive identities on account replacement and reloads within the new session', async () => { + await setup(); + sessionRevision.set(2); + api['list']?.mockReturnValue(of({ member: [], totalItems: 0 })); + api['read']?.mockReturnValue( + of( + maintenanceExportFixture({ + id: 'new-session-archive', + files: { ...archive.files, json: { ...archive.files.json, sha256: 'new-session-hash' } }, + }), + ), + ); + await fixture.whenStable(); + expect(root().textContent).not.toContain('original-json-hash'); + expect(api['list']).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/app/features/organization/features/maintenance-schedules/FEATURE.md b/src/app/features/organization/features/maintenance-schedules/FEATURE.md index 6e4ffbabd..6d2c8e17c 100644 --- a/src/app/features/organization/features/maintenance-schedules/FEATURE.md +++ b/src/app/features/organization/features/maintenance-schedules/FEATURE.md @@ -14,8 +14,8 @@ existing business behavior. ## Purpose -Owns the organization's read surface over the backend Maintenance module and -the two actions it exposes to an operator: +Owns the organization's historical schedules and independent equipment operation +plans over the backend Maintenance module: - listing maintenance schedules — one row per published equipment — with server-side search, filtering and pagination, - overriding a single schedule's inspection interval, or clearing the @@ -24,6 +24,48 @@ the two actions it exposes to an operator: currently due or overdue, optionally narrowed to one facility or equipment type. +## Independent equipment operations + +`/organizations/:organizationId/maintenance/plans` owns the operation library, +guarded by the existing maintenance-read permission and provided with its own +page-scoped `MaintenancePlansStore`. Controls and maintenance use separate +server-filtered queries and exact server totals; neither the browser nor the +list derives deadline, completion, availability or anomaly status. +The equipment dossier may link with `equipmentId` and `operationKind` query +parameters; both are forwarded to the same authorized server collection. + +`MaintenancePlanService` transports the organization-scoped `/maintenance/plans` +resources and `/maintenance/engine` state. A new operation is prepared inactive, +then its next three dates are reviewed from the server before explicit activation. +Day, week, month and year units require an explicit organization choice; the form +does not supply equipment-specific or regulatory frequencies. Historical cadence +and missing first deadlines remain visible. Calendar calculations belong to the API. +Date inputs send `anchorOn`/`nextDueOn` calendar strings for the server to interpret +in its preserved `calendarTimezone`. Fixed calendar dates render without timezone +conversion; historical deadline instants retain the organization date formatter. +Editing sends only changed calendar fields. An open occurrence locks its cadence, +anchor and deadline while leaving the operation name editable, including when the +historical calendar is incomplete. Renaming never reschedules its existing work. + +The page orchestrates its presentational form and flat operation list. Equipment +choices use the public `EquipmentService` and `buildEquipmentTitle` from equipments, +in server pages of 30 with search; reads begin only when opening a new-plan form. +Selected labels survive a different option page. Authenticated plan and engine reads +begin after hydration, with no response serialized into TransferState. + +Configuration, legacy preparation and the consequential authority switch require +maintenance-manage. Generation additionally requires interventions-plan and confirmed +plan-engine authority. Switching uses a native alert dialog, stays open and busy-locked +until confirmation, and renders migration conflicts inline for correction and retry. +Preparation does not switch authority. The server remains the sole arbiter of active +engine and historical campaign linkage. + +Generation is bounded to one equipment operation. Ordinary submission recovers already +created work; a new attempt is a separate action exposed only by `retryAllowed` from the +API. Original due dates and attempt numbers remain visible. Creating work never counts +as completion. Accepted writes survive navigation without replacing another +organization's current page; stale queries and previews are cancelled or ignored. + Named `maintenance-schedules`, not the unqualified `maintenance` — an app-level feature already owns that name for the unrelated app-maintenance-mode page (`src/app/features/maintenance/`), and a collision on `app-maintenance-*` @@ -48,11 +90,13 @@ event-driven recalculation with an hourly recovery sweep and is rendered as-is. ## Routes - `/organizations/:organizationId/maintenance` — `MaintenanceSchedulesPage`: - the only route this subfeature owns. Guarded on + historical schedules, guarded on `organization.maintenance.read` on the pathless parent, the same shape `EQUIPMENT_ROUTES`/`INSPECTION_ROUTES` use. There is no create or detail route — a schedule is derived server-side the moment an equipment of a tracked type exists; it is never authored directly. +- `/organizations/:organizationId/maintenance/plans` — `MaintenancePlansPage`: + the operation library and explicit preparation/configuration workflows. ## State and Data Access @@ -66,6 +110,12 @@ the store also dispatches the success toast itself (`campaignSucceeded`), since the message needs the result's interpolated values. +Historical schedule rows, totals and action states belong to one canonical organization IRI. +Changing organization clears them before the next read; refreshes within the same organization +retain their current rows. Override commands include the originating organization, and campaign +results retain it for intervention navigation. Accepted writes finish without cancellation, but +responses from an earlier organization visit cannot update the current scope or its action feedback. + Primary service: `MaintenanceScheduleService` — extends `HydraApiService` but calls the **canonical** `/api/maintenance/schedules` and `/api/maintenance/campaigns` resources, not an organization-scoped path; the @@ -95,10 +145,11 @@ toast. ## Cross-Feature Dependencies - Depends on organization route context from the parent feature. -- Consumes `EQUIPMENT_TYPE_OPTIONS` from the `equipments` subfeature's - published public API (`@features/organization/features/equipments`) for - the equipment-type filter, the table's type label and the campaign - dialog's scoping select — the same catalog `onboarding` already imports. +- Consumes `EquipmentTypeCatalogStore` and `EquipmentTypeOption` through the + equipments public API for filters, table labels and campaign scope. Server + labels support custom codes and historical archived types. The page loads + this secondary catalog after hydration, forwards options to presentation + components, clears old organization data on scope changes and offers explicit retry. - Consumes `FacilityOptionsStore` through `facilities/state`, `FacilityOption` through `facilities/models`, and the published `FacilityOptionPicker` through `facilities/ui/components` for filter and campaign scope controls. Options @@ -124,6 +175,15 @@ toast. server-side but the UI does not offer the action into it. - A successful override replaces the row from the PATCH response; nothing in this feature refetches the list for it. +- Changing organization clears list filters, search, pagination and open action dialogs. Only + rows owned by the current organization are displayed or offered an override action. +- A late override or campaign response cannot change another organization's state or a later visit + to its original organization. Dialog completion requires the current scope and matching override + target; campaign navigation uses the confirmed result's original organization. +- Accepted overrides and campaigns continue across organization changes. Each operation admits one + write per organization at a time, so another organization can submit immediately. Returning to an + organization with an unfinished write keeps that action pending until it settles; clearing dialog + feedback cannot enable a duplicate, and the earlier visit's result remains ignored. - Status is never colour-only: `MaintenanceDueStatusTag` always pairs its severity tint with an icon and a label (`models/maintenance-tag/`). diff --git a/src/app/features/organization/features/maintenance-schedules/data-access/index.ts b/src/app/features/organization/features/maintenance-schedules/data-access/index.ts index 79fbf0f74..8efd1adfa 100644 --- a/src/app/features/organization/features/maintenance-schedules/data-access/index.ts +++ b/src/app/features/organization/features/maintenance-schedules/data-access/index.ts @@ -1 +1,2 @@ export { MaintenanceScheduleService } from './services/maintenance-schedule/maintenance-schedule.service'; +export { MaintenancePlanService } from './services/maintenance-plan/maintenance-plan.service'; diff --git a/src/app/features/organization/features/maintenance-schedules/data-access/services/maintenance-plan/maintenance-plan.service.ts b/src/app/features/organization/features/maintenance-schedules/data-access/services/maintenance-plan/maintenance-plan.service.ts new file mode 100644 index 000000000..dbda054e2 --- /dev/null +++ b/src/app/features/organization/features/maintenance-schedules/data-access/services/maintenance-plan/maintenance-plan.service.ts @@ -0,0 +1,233 @@ +import { Service } from '@angular/core'; +import type { Observable } from 'rxjs'; +import { HydraApiService, type RequestOptions } from '@core/api'; +import type { HydraCollection } from '@core/api/models'; +import type { + CreateMaintenancePlanInput, + MaintenanceEngineOutput, + MaintenancePlanGenerationOutput, + MaintenancePlanOutput, + MaintenancePlanPreviewOutput, + UpdateMaintenancePlanInput, +} from '@features/organization/features/maintenance-schedules/models'; + +/** + * Class MaintenancePlanService + * @class MaintenancePlanService + * + * @description + * Organization-scoped transport for equipment operation plans and scheduling authority. + */ +@Service() +export class MaintenancePlanService extends HydraApiService { + //#region Methods + /** + * Method list + * @method list + * + * @description + * Reads a server-filtered page of operation plans. + * + * @access public + * @since unreleased + * + * @param {string} organizationId - Owning organization. + * @param {RequestOptions} [options] - Server search, filters and pagination. + * + * @returns {Observable>} Authorized plan page. + */ + public list( + organizationId: string, + options?: RequestOptions, + ): Observable> { + return this.getCollection(this.path(organizationId), options); + } + + /** + * Method create + * @method create + * + * @description + * Prepares an inactive plan for server preview before activation. + * + * @access public + * @since unreleased + * + * @param {string} organizationId - Owning organization. + * @param {CreateMaintenancePlanInput} input - Equipment operation draft. + * + * @returns {Observable} Prepared plan. + */ + public create( + organizationId: string, + input: CreateMaintenancePlanInput, + ): Observable { + return this.post( + this.path(organizationId), + input, + ); + } + + /** + * Method update + * @method update + * + * @description + * Configures or explicitly activates one plan. + * + * @access public + * @since unreleased + * + * @param {string} organizationId - Owning organization. + * @param {string} planId - Plan identity. + * @param {UpdateMaintenancePlanInput} input - Mutable operation configuration. + * + * @returns {Observable} Updated authoritative plan. + */ + public update( + organizationId: string, + planId: string, + input: UpdateMaintenancePlanInput, + ): Observable { + return this.patch( + this.path(organizationId, planId), + input, + ); + } + + /** + * Method preview + * @method preview + * + * @description + * Reads three server-calculated future dates without advancing the plan. + * + * @access public + * @since unreleased + * + * @param {string} organizationId - Owning organization. + * @param {string} planId - Plan to review. + * + * @returns {Observable} Authoritative preview dates. + */ + public preview(organizationId: string, planId: string): Observable { + return this.http.get( + this.buildUrl(`${this.path(organizationId, planId)}/preview`), + { headers: this.buildHeaders(), withCredentials: true }, + ); + } + + /** + * Method generate + * @method generate + * + * @description + * Creates or recovers bounded work for a single plan; retry is always explicit. + * + * @access public + * @since unreleased + * + * @param {string} organizationId - Owning organization. + * @param {string} planId - Due equipment operation. + * @param {boolean} retry - Requests a new attempt after a server-recognized failure. + * + * @returns {Observable} Work result. + */ + public generate( + organizationId: string, + planId: string, + retry: boolean, + ): Observable { + return this.http.post( + this.buildUrl(`${this.path(organizationId, planId)}/generate`), + { retry }, + { headers: this.buildHeaders(), withCredentials: true }, + ); + } + + /** + * Method engine + * @method engine + * + * @description + * Reads the organization's sole active scheduling authority. + * + * @access public + * @since unreleased + * + * @param {string} organizationId - Owning organization. + * + * @returns {Observable} Current engine state. + */ + public engine(organizationId: string): Observable { + return this.http.get( + this.buildUrl(`/api/organizations/${organizationId}/maintenance/engine`), + { headers: this.buildHeaders(), withCredentials: true }, + ); + } + + /** + * Method prepareLegacy + * @method prepareLegacy + * + * @description + * Prepares historical control plans without changing the active engine. + * + * @access public + * @since unreleased + * + * @param {string} organizationId - Owning organization. + * + * @returns {Observable} Preparation state. + */ + public prepareLegacy(organizationId: string): Observable { + return this.http.post( + this.buildUrl(`${this.path(organizationId)}/prepare-legacy`), + {}, + { headers: this.buildHeaders(), withCredentials: true }, + ); + } + + /** + * Method activateEngine + * @method activateEngine + * + * @description + * Explicitly switches authority after the server verifies historical conflicts. + * + * @access public + * @since unreleased + * + * @param {string} organizationId - Owning organization. + * + * @returns {Observable} Confirmed engine state. + */ + public activateEngine(organizationId: string): Observable { + return this.http.post( + this.buildUrl(`${this.path(organizationId)}/activate`), + {}, + { headers: this.buildHeaders(), withCredentials: true }, + ); + } + + /** + * Method path + * @method path + * + * @description + * Builds the organization-owned plan resource path. + * + * @access private + * @since unreleased + * + * @param {string} organizationId - Owning organization. + * @param {string} [planId] - Optional plan identity. + * + * @returns {string} Resource path. + */ + private path(organizationId: string, planId?: string): string { + const base: string = `/api/organizations/${organizationId}/maintenance/plans`; + return planId ? `${base}/${planId}` : base; + } + //#endregion +} diff --git a/src/app/features/organization/features/maintenance-schedules/data-access/services/maintenance-plan/testing/maintenance-plan.service.spec.ts b/src/app/features/organization/features/maintenance-schedules/data-access/services/maintenance-plan/testing/maintenance-plan.service.spec.ts new file mode 100644 index 000000000..775f37ed0 --- /dev/null +++ b/src/app/features/organization/features/maintenance-schedules/data-access/services/maintenance-plan/testing/maintenance-plan.service.spec.ts @@ -0,0 +1,116 @@ +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { ENV_CONFIG } from '@core/config/environment/env.token'; +import { MaintenancePlanService } from '../maintenance-plan.service'; + +describe('MaintenancePlanService', () => { + let service: MaintenancePlanService; + let httpMock: HttpTestingController; + const base = 'https://api.test/api/organizations/org-1/maintenance/plans'; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + MaintenancePlanService, + { provide: ENV_CONFIG, useValue: { apiUrl: 'https://api.test' } }, + ], + }); + service = TestBed.inject(MaintenancePlanService); + httpMock = TestBed.inject(HttpTestingController); + }); + afterEach(() => httpMock.verify()); + + it('forwards independent operation kind, search and server pagination', () => { + service + .list('org-1', { + page: 2, + itemsPerPage: 30, + search: 'Annual', + params: { operationKind: 'control' }, + }) + .subscribe(); + const request = httpMock.expectOne((candidate) => candidate.url === base); + expect(request.request.method).toBe('GET'); + expect(request.request.params.get('operationKind')).toBe('control'); + expect(request.request.params.get('search')).toBe('Annual'); + expect(request.request.params.get('page')).toBe('2'); + request.flush({ '@id': base, '@type': 'Collection', member: [], totalItems: 0 }); + }); + + it('prepares a plan without setting activation or reusing the offline clientId', () => { + const input = { + equipmentId: 'equipment-1', + name: 'Monthly service', + operationKind: 'maintenance' as const, + interval: 'P1M', + anchorAt: '2026-01-31T00:00:00Z', + }; + service.create('org-1', input).subscribe(); + const request = httpMock.expectOne(base); + expect(request.request.method).toBe('POST'); + expect(request.request.body).toEqual(input); + expect(request.request.body.active).toBeUndefined(); + expect(request.request.body.clientId).toBeUndefined(); + request.flush({ '@id': `${base}/plan-1`, '@type': 'MaintenancePlan', id: 'plan-1' }); + }); + + it('reads preview from the server without calendar query parameters', () => { + let dates: readonly string[] = []; + service.preview('org-1', 'plan-1').subscribe((result) => (dates = result.dates)); + const request = httpMock.expectOne(`${base}/plan-1/preview`); + expect(request.request.method).toBe('GET'); + request.flush({ dates: ['2026-02-28', '2026-03-31', '2026-04-30'] }); + expect(dates).toEqual(['2026-02-28', '2026-03-31', '2026-04-30']); + }); + + it('sends only explicit activation in the merge patch', () => { + service.update('org-1', 'plan-1', { active: true }).subscribe(); + const request = httpMock.expectOne(`${base}/plan-1`); + expect(request.request.method).toBe('PATCH'); + expect(request.request.headers.get('Content-Type')).toBe('application/merge-patch+json'); + expect(request.request.body).toEqual({ active: true }); + request.flush({ + '@id': `${base}/plan-1`, + '@type': 'MaintenancePlan', + id: 'plan-1', + active: true, + }); + }); + + it('keeps recovered generation separate from a requested new attempt', () => { + service.generate('org-1', 'plan-1', false).subscribe(); + const request = httpMock.expectOne(`${base}/plan-1/generate`); + expect(request.request.body).toEqual({ retry: false }); + request.flush({ + occurrenceId: 'occurrence-1', + interventionId: 'work-1', + number: 1, + workItemsCount: 1, + replayed: true, + }); + service.generate('org-1', 'plan-1', true).subscribe(); + const retry = httpMock.expectOne(`${base}/plan-1/generate`); + expect(retry.request.body).toEqual({ retry: true }); + retry.flush({ + occurrenceId: 'occurrence-1', + interventionId: 'work-2', + number: 2, + workItemsCount: 1, + replayed: false, + }); + }); + + it('prepares legacy and switches authority through distinct commands', () => { + service.prepareLegacy('org-1').subscribe(); + const prepared = httpMock.expectOne(`${base}/prepare-legacy`); + expect(prepared.request.body).toEqual({}); + prepared.flush({ mode: 'legacy', preparedCount: 5 }); + service.activateEngine('org-1').subscribe(); + const activated = httpMock.expectOne(`${base}/activate`); + expect(activated.request.method).toBe('POST'); + activated.flush({ mode: 'plans', preparedCount: 5 }); + }); +}); diff --git a/src/app/features/organization/features/maintenance-schedules/maintenance-schedules.routes.ts b/src/app/features/organization/features/maintenance-schedules/maintenance-schedules.routes.ts index db1838b43..865b3ee3a 100644 --- a/src/app/features/organization/features/maintenance-schedules/maintenance-schedules.routes.ts +++ b/src/app/features/organization/features/maintenance-schedules/maintenance-schedules.routes.ts @@ -1,28 +1,28 @@ import type { Routes } from '@angular/router'; import { organizationPermissionGuard } from '@features/organization/http/guards'; import { ORGANIZATION_PERMISSION } from '@features/organization/models'; -import { MaintenanceSchedulesStore } from './state'; +import { MaintenancePlansStore, MaintenanceSchedulesStore } from './state'; /** * Constant MAINTENANCE_SCHEDULE_ROUTES - * @const MAINTENANCE_SCHEDULE_ROUTES * * @description - * Organization-scoped maintenance schedules: a single index page at - * `/organizations/:organizationId/maintenance`. Named `maintenance-schedules` + * Organization-scoped historical schedules and independent equipment operation plans at + * `/organizations/:organizationId/maintenance` and its `/plans` library. Named `maintenance-schedules` * — not the unqualified `maintenance` an app-level feature already owns (the * app-maintenance-mode page) — to avoid a selector and folder collision. * * The read permission guard sits on the pathless parent, the same shape * `EQUIPMENT_ROUTES` and `INTERVENTION_ROUTES` use: it re-runs on an * organization switch because the params change. There is no create or - * detail route — schedules are derived server-side from tracked equipment, - * never authored directly — so `MaintenanceSchedulesStore` is provided once, - * on the single leaf, rather than per several routes. + * detail route for historical schedules — they are derived server-side from tracked equipment. + * Each leaf provides its own store; operation plans are prepared through their library. * * @since 1.0.0 * * @type {Routes} + * + * @const MAINTENANCE_SCHEDULE_ROUTES */ export const MAINTENANCE_SCHEDULE_ROUTES: Routes = [ { @@ -32,6 +32,14 @@ export const MAINTENANCE_SCHEDULE_ROUTES: Routes = [ ], data: { breadcrumb: false }, children: [ + { + path: 'plans', + providers: [MaintenancePlansStore], + loadComponent: () => + import('./ui/pages/maintenance-plans-page').then((m) => m.MaintenancePlansPage), + title: $localize`:@@route.maintenancePlans:Operation plans`, + data: { breadcrumb: false }, + }, { path: '', pathMatch: 'full', diff --git a/src/app/features/organization/features/maintenance-schedules/models/index.ts b/src/app/features/organization/features/maintenance-schedules/models/index.ts index c13b040d2..93720960a 100644 --- a/src/app/features/organization/features/maintenance-schedules/models/index.ts +++ b/src/app/features/organization/features/maintenance-schedules/models/index.ts @@ -11,3 +11,15 @@ export type { MaintenanceCampaignOutput } from './maintenance-campaign/maintenan export type { MaintenanceTagDescriptor } from './maintenance-tag/maintenance-tag-descriptor.interface'; export type { MaintenanceTagSeverity } from './maintenance-tag/maintenance-tag-severity.type'; export { resolveMaintenanceTag } from './maintenance-tag/maintenance-tag.util'; +export type { MaintenanceOperationKind } from './maintenance-plan/maintenance-operation-kind.type'; +export type { + MaintenancePlanOutput, + MaintenancePlanOccurrenceOutput, +} from './maintenance-plan/maintenance-plan-output.interface'; +export type { + CreateMaintenancePlanInput, + UpdateMaintenancePlanInput, +} from './maintenance-plan/create-maintenance-plan-input.interface'; +export type { MaintenancePlanPreviewOutput } from './maintenance-plan/maintenance-plan-preview-output.interface'; +export type { MaintenanceEngineOutput } from './maintenance-plan/maintenance-engine-output.interface'; +export type { MaintenancePlanGenerationOutput } from './maintenance-plan/maintenance-plan-generation-output.interface'; diff --git a/src/app/features/organization/features/maintenance-schedules/models/maintenance-plan/create-maintenance-plan-input.interface.ts b/src/app/features/organization/features/maintenance-schedules/models/maintenance-plan/create-maintenance-plan-input.interface.ts new file mode 100644 index 000000000..bc90bd721 --- /dev/null +++ b/src/app/features/organization/features/maintenance-schedules/models/maintenance-plan/create-maintenance-plan-input.interface.ts @@ -0,0 +1,201 @@ +import type { MaintenanceOperationKind } from './maintenance-operation-kind.type'; + +/** + * Interface CreateMaintenancePlanInput + * @interface CreateMaintenancePlanInput + * + * @description + * Prepares an inactive equipment operation for date preview before activation. + */ +export interface CreateMaintenancePlanInput { + /** + * Property equipmentId + * @readonly + * + * @description + * Equipment selected from the authorized organization. + * + * @since unreleased + * + * @type {string} + */ + readonly equipmentId: string; + /** + * Property name + * @readonly + * + * @description + * Operation name. + * + * @since unreleased + * + * @type {string} + */ + readonly name: string; + /** + * Property operationKind + * @readonly + * + * @description + * Kind remains immutable after creation. + * + * @since unreleased + * + * @type {MaintenanceOperationKind} + */ + readonly operationKind: MaintenanceOperationKind; + /** + * Property interval + * @readonly + * + * @description + * Positive ISO calendar duration. + * + * @since unreleased + * + * @type {string} + */ + readonly interval: string; + /** + * Property anchorAt + * @readonly + * + * @description + * Calendar anchor instant. + * + * @since unreleased + * + * @type {string} + */ + readonly anchorAt?: string; + /** + * Property anchorOn + * @readonly + * + * @description + * Calendar date interpreted in the server-owned plan timezone, without browser conversion. + * + * @since unreleased + * + * @type {string} + */ + readonly anchorOn?: string; + /** + * Property nextDueAt + * @readonly + * + * @description + * Optional explicit first deadline. + * + * @since unreleased + * + * @type {string} + */ + readonly nextDueAt?: string; + /** + * Property nextDueOn + * @readonly + * + * @description + * Optional first calendar deadline interpreted by the server. + * + * @since unreleased + * + * @type {string} + */ + readonly nextDueOn?: string; +} + +/** + * Interface UpdateMaintenancePlanInput + * @interface UpdateMaintenancePlanInput + * + * @description + * Configurable plan fields; equipment and operation kind are immutable. + */ +export interface UpdateMaintenancePlanInput { + /** + * Property name + * @readonly + * + * @description + * Replacement operation name. + * + * @since unreleased + * + * @type {string} + */ + readonly name?: string; + /** + * Property interval + * @readonly + * + * @description + * Configured calendar duration. + * + * @since unreleased + * + * @type {string} + */ + readonly interval?: string; + /** + * Property anchorAt + * @readonly + * + * @description + * Updated calendar anchor. + * + * @since unreleased + * + * @type {string} + */ + readonly anchorAt?: string; + /** + * Property anchorOn + * @readonly + * + * @description + * Updated anchor day in the plan's preserved timezone. + * + * @since unreleased + * + * @type {string} + */ + readonly anchorOn?: string; + /** + * Property nextDueAt + * @readonly + * + * @description + * Explicit first or next due date. + * + * @since unreleased + * + * @type {string} + */ + readonly nextDueAt?: string; + /** + * Property nextDueOn + * @readonly + * + * @description + * Explicit initial deadline day in the plan's preserved timezone. + * + * @since unreleased + * + * @type {string} + */ + readonly nextDueOn?: string; + /** + * Property active + * @readonly + * + * @description + * Explicit activation after date review. + * + * @since unreleased + * + * @type {boolean} + */ + readonly active?: boolean; +} diff --git a/src/app/features/organization/features/maintenance-schedules/models/maintenance-plan/maintenance-engine-output.interface.ts b/src/app/features/organization/features/maintenance-schedules/models/maintenance-plan/maintenance-engine-output.interface.ts new file mode 100644 index 000000000..d68ace86f --- /dev/null +++ b/src/app/features/organization/features/maintenance-schedules/models/maintenance-plan/maintenance-engine-output.interface.ts @@ -0,0 +1,45 @@ +/** + * Interface MaintenanceEngineOutput + * @interface MaintenanceEngineOutput + * + * @description + * Organization's single active scheduling authority and migration preparation state. + */ +export interface MaintenanceEngineOutput { + /** + * Property mode + * @readonly + * + * @description + * Sole authority currently allowed to generate work. + * + * @since unreleased + * + * @type {'legacy' | 'plans'} + */ + readonly mode: 'legacy' | 'plans'; + /** + * Property preparedCount + * @readonly + * + * @description + * Number of historical plans prepared server-side. + * + * @since unreleased + * + * @type {number} + */ + readonly preparedCount: number; + /** + * Property conflicts + * @readonly + * + * @description + * Server-owned migration conflicts, if any. + * + * @since unreleased + * + * @type {readonly string[]} + */ + readonly conflicts?: readonly string[]; +} diff --git a/src/app/features/organization/features/maintenance-schedules/models/maintenance-plan/maintenance-operation-kind.type.ts b/src/app/features/organization/features/maintenance-schedules/models/maintenance-plan/maintenance-operation-kind.type.ts new file mode 100644 index 000000000..0c0a712ae --- /dev/null +++ b/src/app/features/organization/features/maintenance-schedules/models/maintenance-plan/maintenance-operation-kind.type.ts @@ -0,0 +1,9 @@ +/** + * Type MaintenanceOperationKind + * + * @description + * Separates an equipment control from its independent maintenance operations. + * + * @type MaintenanceOperationKind + */ +export type MaintenanceOperationKind = 'control' | 'maintenance'; diff --git a/src/app/features/organization/features/maintenance-schedules/models/maintenance-plan/maintenance-plan-generation-output.interface.ts b/src/app/features/organization/features/maintenance-schedules/models/maintenance-plan/maintenance-plan-generation-output.interface.ts new file mode 100644 index 000000000..ad9d28263 --- /dev/null +++ b/src/app/features/organization/features/maintenance-schedules/models/maintenance-plan/maintenance-plan-generation-output.interface.ts @@ -0,0 +1,69 @@ +/** + * Interface MaintenancePlanGenerationOutput + * @interface MaintenancePlanGenerationOutput + * + * @description + * Bounded work creation or recovery for one equipment operation. + */ +export interface MaintenancePlanGenerationOutput { + /** + * Property occurrenceId + * @readonly + * + * @description + * Stable due occurrence. + * + * @since unreleased + * + * @type {string} + */ + readonly occurrenceId: string; + /** + * Property interventionId + * @readonly + * + * @description + * Created or recovered work identity. + * + * @since unreleased + * + * @type {string} + */ + readonly interventionId: string; + /** + * Property number + * @readonly + * + * @description + * Human-readable work number. + * + * @since unreleased + * + * @type {number} + */ + readonly number: number; + /** + * Property workItemsCount + * @readonly + * + * @description + * Server-reported work size. + * + * @since unreleased + * + * @type {number} + */ + readonly workItemsCount: number; + /** + * Property replayed + * @readonly + * + * @description + * Indicates reuse of already-created work. + * + * @since unreleased + * + * @type {boolean} + */ + readonly replayed: boolean; +} diff --git a/src/app/features/organization/features/maintenance-schedules/models/maintenance-plan/maintenance-plan-output.interface.ts b/src/app/features/organization/features/maintenance-schedules/models/maintenance-plan/maintenance-plan-output.interface.ts new file mode 100644 index 000000000..9a34c9c82 --- /dev/null +++ b/src/app/features/organization/features/maintenance-schedules/models/maintenance-plan/maintenance-plan-output.interface.ts @@ -0,0 +1,334 @@ +import type { HydraItem } from '@core/api/models'; +import type { MaintenanceOperationKind } from './maintenance-operation-kind.type'; + +/** + * Interface MaintenancePlanOccurrenceOutput + * @interface MaintenancePlanOccurrenceOutput + * + * @description + * Original due occurrence and its current execution attempt, as evaluated by the server. + */ +export interface MaintenancePlanOccurrenceOutput { + /** + * Property id + * @readonly + * + * @description + * Stable occurrence identity. + * + * @since unreleased + * + * @type {string} + */ + readonly id: string; + /** + * Property dueAt + * @readonly + * + * @description + * Original due instant, retained across attempts. + * + * @since unreleased + * + * @type {string} + */ + readonly dueAt: string; + /** + * Property attempt + * @readonly + * + * @description + * Current execution attempt number. + * + * @since unreleased + * + * @type {number} + */ + readonly attempt: number; + /** + * Property interventionId + * @readonly + * + * @description + * Work supporting the current attempt. + * + * @since unreleased + * + * @type {string | null} + */ + readonly interventionId?: string | null; + /** + * Property number + * @readonly + * + * @description + * Current intervention number when generated. + * + * @since unreleased + * + * @type {number | null} + */ + readonly number?: number | null; + /** + * Property status + * @readonly + * + * @description + * Server-owned occurrence state. + * + * @since unreleased + * + * @type {string} + */ + readonly status: 'open' | 'completed'; + /** + * Property retryAllowed + * @readonly + * + * @description + * Whether an explicit new execution attempt is allowed. + * + * @since unreleased + * + * @type {boolean} + */ + readonly retryAllowed?: boolean; +} + +/** + * Interface MaintenancePlanOutput + * @interface MaintenancePlanOutput + * + * @description + * Organization-owned equipment operation. Due dates and completion remain backend-owned. + */ +export interface MaintenancePlanOutput extends HydraItem { + /** + * Property id + * @readonly + * + * @description + * Plan identity. + * + * @since unreleased + * + * @type {string} + */ + readonly id: string; + /** + * Property organizationId + * @readonly + * + * @description + * Owning organization. + * + * @since unreleased + * + * @type {string} + */ + readonly organizationId: string; + /** + * Property equipmentId + * @readonly + * + * @description + * Equipment receiving the operation. + * + * @since unreleased + * + * @type {string} + */ + readonly equipmentId: string; + /** + * Property facilityId + * @readonly + * + * @description + * Equipment's current site. + * + * @since unreleased + * + * @type {string | null} + */ + readonly facilityId?: string | null; + /** + * Property equipmentType + * @readonly + * + * @description + * Stable equipment type code. + * + * @since unreleased + * + * @type {string} + */ + readonly equipmentType: string; + /** + * Property name + * @readonly + * + * @description + * Organization-authored operation name. + * + * @since unreleased + * + * @type {string} + */ + readonly name: string; + /** + * Property operationKind + * @readonly + * + * @description + * Control or maintenance operation. + * + * @since unreleased + * + * @type {MaintenanceOperationKind} + */ + readonly operationKind: MaintenanceOperationKind; + /** + * Property interval + * @readonly + * + * @description + * Calendar duration configured by the organization. + * + * @since unreleased + * + * @type {string} + */ + readonly interval: string; + /** + * Property cadenceMode + * @readonly + * + * @description + * Anchored or preserved historical recurrence. + * + * @since unreleased + * + * @type {'fixed' | 'legacy'} + */ + readonly cadenceMode: 'fixed' | 'legacy'; + /** + * Property calendarTimezone + * @readonly + * + * @description + * Server-owned IANA timezone preserved for the anchored operation calendar. + * + * @since unreleased + * + * @type {string} + */ + readonly calendarTimezone?: string; + /** + * Property anchorAt + * @readonly + * + * @description + * Calendar anchor instant. + * + * @since unreleased + * + * @type {string} + */ + readonly anchorAt?: string | null; + /** + * Property nextDueAt + * @readonly + * + * @description + * Server-computed next due instant. + * + * @since unreleased + * + * @type {string | null} + */ + readonly nextDueAt?: string | null; + /** + * Property active + * @readonly + * + * @description + * Whether this plan may generate work. + * + * @since unreleased + * + * @type {boolean} + */ + readonly active: boolean; + /** + * Property legacyScheduleId + * @readonly + * + * @description + * Preserved legacy schedule when migrated. + * + * @since unreleased + * + * @type {string | null} + */ + readonly legacyScheduleId?: string | null; + /** + * Property lastCompletedAt + * @readonly + * + * @description + * Most recent validated completion. + * + * @since unreleased + * + * @type {string | null} + */ + readonly lastCompletedAt?: string | null; + /** + * Property openOccurrence + * @readonly + * + * @description + * Due work retains its original deadline and current attempt. + * + * @since unreleased + * + * @type {MaintenancePlanOccurrenceOutput | null} + */ + readonly openOccurrence?: MaintenancePlanOccurrenceOutput | null; + /** + * Property archivedAt + * @readonly + * + * @description + * Archive instant when the plan leaves scheduling. + * + * @since unreleased + * + * @type {string | null} + */ + readonly archivedAt?: string | null; + /** + * Property createdAt + * @readonly + * + * @description + * Creation instant. + * + * @since unreleased + * + * @type {string} + */ + readonly createdAt?: string; + /** + * Property updatedAt + * @readonly + * + * @description + * Most recent configuration change. + * + * @since unreleased + * + * @type {string} + */ + readonly updatedAt?: string; +} diff --git a/src/app/features/organization/features/maintenance-schedules/models/maintenance-plan/maintenance-plan-preview-output.interface.ts b/src/app/features/organization/features/maintenance-schedules/models/maintenance-plan/maintenance-plan-preview-output.interface.ts new file mode 100644 index 000000000..27114490f --- /dev/null +++ b/src/app/features/organization/features/maintenance-schedules/models/maintenance-plan/maintenance-plan-preview-output.interface.ts @@ -0,0 +1,21 @@ +/** + * Interface MaintenancePlanPreviewOutput + * @interface MaintenancePlanPreviewOutput + * + * @description + * Backend-calculated dates; the browser never implements calendar recurrence. + */ +export interface MaintenancePlanPreviewOutput { + /** + * Property dates + * @readonly + * + * @description + * Next three calendar deadlines, when configured. + * + * @since unreleased + * + * @type {readonly string[]} + */ + readonly dates: readonly string[]; +} diff --git a/src/app/features/organization/features/maintenance-schedules/state/index.ts b/src/app/features/organization/features/maintenance-schedules/state/index.ts index f9234400c..45621157c 100644 --- a/src/app/features/organization/features/maintenance-schedules/state/index.ts +++ b/src/app/features/organization/features/maintenance-schedules/state/index.ts @@ -3,3 +3,8 @@ export { type MaintenanceSchedulesStoreType, maintenanceSchedulesStoreEvents, } from './maintenance-schedules'; +export { + MaintenancePlansStore, + type MaintenancePlansStoreType, + maintenancePlansStoreEvents, +} from './maintenance-plans'; diff --git a/src/app/features/organization/features/maintenance-schedules/state/maintenance-plans/events/events.ts b/src/app/features/organization/features/maintenance-schedules/state/maintenance-plans/events/events.ts new file mode 100644 index 000000000..7e4ee3753 --- /dev/null +++ b/src/app/features/organization/features/maintenance-schedules/state/maintenance-plans/events/events.ts @@ -0,0 +1,17 @@ +import { type } from '@ngrx/signals'; +import { eventGroup } from '@ngrx/signals/events'; +import type { MaintenancePlanOutput } from '@features/organization/features/maintenance-schedules/models'; + +/** + * Constant maintenancePlansStoreEvents + * + * @description + * Confirmed command consequences, carrying their original organization context. + */ +export const maintenancePlansStoreEvents = eventGroup({ + source: 'Maintenance Plans Store', + events: { + planPrepared: type<{ organizationId: string; plan: MaintenancePlanOutput }>(), + planChanged: type<{ organizationId: string }>(), + }, +}); diff --git a/src/app/features/organization/features/maintenance-schedules/state/maintenance-plans/index.ts b/src/app/features/organization/features/maintenance-schedules/state/maintenance-plans/index.ts new file mode 100644 index 000000000..a0c33c11a --- /dev/null +++ b/src/app/features/organization/features/maintenance-schedules/state/maintenance-plans/index.ts @@ -0,0 +1,2 @@ +export { MaintenancePlansStore, type MaintenancePlansStoreType } from './maintenance-plans.store'; +export { maintenancePlansStoreEvents } from './events/events'; diff --git a/src/app/features/organization/features/maintenance-schedules/state/maintenance-plans/maintenance-plans.store.ts b/src/app/features/organization/features/maintenance-schedules/state/maintenance-plans/maintenance-plans.store.ts new file mode 100644 index 000000000..6eb2c1b3a --- /dev/null +++ b/src/app/features/organization/features/maintenance-schedules/state/maintenance-plans/maintenance-plans.store.ts @@ -0,0 +1,442 @@ +import { computed, inject } from '@angular/core'; +import { tapResponse } from '@ngrx/operators'; +import { patchState, signalStore, type, withComputed, withMethods, withState } from '@ngrx/signals'; +import { removeAllEntities, setAllEntities, setEntity, withEntities } from '@ngrx/signals/entities'; +import { Dispatcher } from '@ngrx/signals/events'; +import { rxMethod } from '@ngrx/signals/rxjs-interop'; +import { EMPTY, exhaustMap, pipe, switchMap } from 'rxjs'; +import type { RequestOptions } from '@core/api'; +import { + errorCallState, + idleCallState, + pendingCallState, + successCallState, + toStoreError, +} from '@core/request-state'; +import { EquipmentService } from '@features/organization/features/equipments/data-access'; +import { MaintenancePlanService } from '@features/organization/features/maintenance-schedules/data-access'; +import type { + CreateMaintenancePlanInput, + MaintenancePlanOutput, + UpdateMaintenancePlanInput, +} from '@features/organization/features/maintenance-schedules/models'; +import { maintenancePlansStoreEvents } from './events/events'; +import type { MaintenancePlansState } from './models/state.interface'; + +/** + * Constant INITIAL_STATE + * + * @description + * Empty page-scoped operation state; accepted writes are never cancelled by a scope change. + */ +const INITIAL_STATE: MaintenancePlansState = { + organizationId: '', + totalPlans: 0, + listCallState: idleCallState(), + engineCallState: idleCallState(), + migrationCallState: idleCallState(), + createCallState: idleCallState(), + updateCallState: idleCallState(), + previewCallState: idleCallState(), + generationCallState: idleCallState(), + selectedPlan: null, + equipmentCallState: idleCallState(), + totalEquipment: 0, +}; + +/** + * Constant MaintenancePlansStore + * + * @description + * Page-scoped equipment operations, server calendar previews and explicit scheduling migration. + * Old reads and late writes never replace a newly selected organization's data. + */ +export const MaintenancePlansStore = signalStore( + withEntities({ entity: type(), collection: 'plan' }), + withState(INITIAL_STATE), + withComputed((store) => ({ + /** + * Property commandPending + * + * @description + * Prevents competing plan and migration submissions. + * + * @since unreleased + */ + commandPending: computed(() => + [ + store.createCallState(), + store.updateCallState(), + store.generationCallState(), + store.migrationCallState(), + ].some((state) => state.status === 'pending'), + ), + })), + withMethods( + ( + store, + service = inject(MaintenancePlanService), + equipmentService = inject(EquipmentService), + dispatcher = inject(Dispatcher), + ) => ({ + /** + * Method setScope + * @method setScope + * + * @description + * Clears the previous organization's view without cancelling its accepted commands. + * + * @access public + * @since unreleased + * + * @param {string} organizationId - Newly displayed organization. + * + * @returns {void} Clears local data only when scope changes. + */ + setScope(organizationId: string): void { + if (organizationId !== store.organizationId()) { + patchState(store, removeAllEntities({ collection: 'plan' }), { + ...INITIAL_STATE, + organizationId, + }); + } + }, + /** + * Method load + * @method load + * + * @description + * Replaces the server-filtered page; latest query wins. + * + * @access public + * @since unreleased + * + * @type {RxMethod<{ organizationId: string; options?: RequestOptions }>} + */ + load: rxMethod<{ organizationId: string; options?: RequestOptions }>( + pipe( + switchMap(({ organizationId, options }) => { + patchState(store, { listCallState: pendingCallState() }); + return service.list(organizationId, options).pipe( + tapResponse({ + next: (response) => { + if (store.organizationId() !== organizationId) return; + patchState(store, setAllEntities([...response.member], { collection: 'plan' }), { + totalPlans: response.totalItems, + listCallState: successCallState(null), + selectedPlan: + response.member.find((plan) => plan.id === store.selectedPlan()?.id) ?? + store.selectedPlan(), + }); + }, + error: (error: unknown) => { + if (store.organizationId() === organizationId) + patchState(store, { listCallState: errorCallState(toStoreError(error)) }); + }, + }), + ); + }), + ), + ), + /** + * Method loadEngine + * @method loadEngine + * + * @description + * Reads the active authority without inferring mode from equipment or plans. + * + * @access public + * @since unreleased + * + * @type {RxMethod} + */ + loadEngine: rxMethod( + pipe( + switchMap((organizationId) => { + patchState(store, { engineCallState: pendingCallState(store.engineCallState().data) }); + return service.engine(organizationId).pipe( + tapResponse({ + next: (result) => { + if (store.organizationId() === organizationId) + patchState(store, { engineCallState: successCallState(result) }); + }, + error: (error: unknown) => { + if (store.organizationId() === organizationId) + patchState(store, { + engineCallState: errorCallState( + toStoreError(error), + store.engineCallState().data, + ), + }); + }, + }), + ); + }), + ), + ), + /** + * Method loadEquipment + * @method loadEquipment + * + * @description + * Searches authorized equipment on browser demand, one bounded server page at a time. + * + * @access public + * @since unreleased + * + * @type {RxMethod<{ organizationId: string; search: string; page: number }>} + */ + loadEquipment: rxMethod<{ organizationId: string; search: string; page: number }>( + pipe( + switchMap(({ organizationId, search, page }) => { + patchState(store, { + equipmentCallState: pendingCallState(store.equipmentCallState().data), + }); + return equipmentService.list(organizationId, { search, page, itemsPerPage: 30 }).pipe( + tapResponse({ + next: (result) => { + if (store.organizationId() === organizationId) + patchState(store, { + equipmentCallState: successCallState(result.member), + totalEquipment: result.totalItems, + }); + }, + error: (error: unknown) => { + if (store.organizationId() === organizationId) + patchState(store, { + equipmentCallState: errorCallState( + toStoreError(error), + store.equipmentCallState().data, + ), + }); + }, + }), + ); + }), + ), + ), + /** + * Method create + * @method create + * + * @description + * Prepares a plan; concurrent submissions cannot cancel an accepted write. + * + * @access public + * @since unreleased + * + * @type {RxMethod<{ organizationId: string; input: CreateMaintenancePlanInput }>} + */ + create: rxMethod<{ organizationId: string; input: CreateMaintenancePlanInput }>( + pipe( + exhaustMap(({ organizationId, input }) => { + patchState(store, { createCallState: pendingCallState() }); + return service.create(organizationId, input).pipe( + tapResponse({ + next: (plan) => { + dispatcher.dispatch( + maintenancePlansStoreEvents.planPrepared({ organizationId, plan }), + ); + if (store.organizationId() === organizationId) + patchState(store, { + createCallState: successCallState(plan), + selectedPlan: plan, + }); + }, + error: (error: unknown) => { + if (store.organizationId() === organizationId) + patchState(store, { createCallState: errorCallState(toStoreError(error)) }); + }, + }), + ); + }), + ), + ), + /** + * Method preview + * @method preview + * + * @description + * Selects a saved plan and fetches its backend dates; a stale preview cannot replace another + * selection. + * + * @access public + * @since unreleased + * + * @type {RxMethod} + */ + preview: rxMethod( + pipe( + switchMap((plan) => { + patchState(store, { + selectedPlan: plan, + previewCallState: plan ? pendingCallState() : idleCallState(), + }); + if (!plan) return EMPTY; + return service.preview(plan.organizationId, plan.id).pipe( + tapResponse({ + next: (result) => { + if ( + store.organizationId() === plan.organizationId && + store.selectedPlan()?.id === plan.id + ) + patchState(store, { previewCallState: successCallState(result) }); + }, + error: (error: unknown) => { + if ( + store.organizationId() === plan.organizationId && + store.selectedPlan()?.id === plan.id + ) + patchState(store, { previewCallState: errorCallState(toStoreError(error)) }); + }, + }), + ); + }), + ), + ), + /** + * Method update + * @method update + * + * @description + * Applies explicit configuration or activation after preview review. + * + * @access public + * @since unreleased + * + * @type {RxMethod<{ + * organizationId: string; + * planId: string; + * input: UpdateMaintenancePlanInput; + * }>} + */ + update: rxMethod<{ + organizationId: string; + planId: string; + input: UpdateMaintenancePlanInput; + }>( + pipe( + exhaustMap(({ organizationId, planId, input }) => { + patchState(store, { updateCallState: pendingCallState() }); + return service.update(organizationId, planId, input).pipe( + tapResponse({ + next: (plan) => { + if (store.organizationId() === organizationId) { + patchState(store, setEntity(plan, { collection: 'plan' }), { + updateCallState: successCallState(plan), + selectedPlan: + store.selectedPlan()?.id === planId ? plan : store.selectedPlan(), + }); + } + dispatcher.dispatch(maintenancePlansStoreEvents.planChanged({ organizationId })); + }, + error: (error: unknown) => { + if (store.organizationId() === organizationId) + patchState(store, { updateCallState: errorCallState(toStoreError(error)) }); + }, + }), + ); + }), + ), + ), + /** + * Method generate + * @method generate + * + * @description + * Creates or recovers a single operation's work; only an explicit retry requests another + * attempt. + * + * @access public + * @since unreleased + * + * @type {RxMethod<{ organizationId: string; planId: string; retry: boolean }>} + */ + generate: rxMethod<{ organizationId: string; planId: string; retry: boolean }>( + pipe( + exhaustMap(({ organizationId, planId, retry }) => { + patchState(store, { generationCallState: pendingCallState() }); + return service.generate(organizationId, planId, retry).pipe( + tapResponse({ + next: (result) => { + if (store.organizationId() === organizationId) + patchState(store, { generationCallState: successCallState(result) }); + dispatcher.dispatch(maintenancePlansStoreEvents.planChanged({ organizationId })); + }, + error: (error: unknown) => { + if (store.organizationId() === organizationId) + patchState(store, { generationCallState: errorCallState(toStoreError(error)) }); + }, + }), + ); + }), + ), + ), + /** + * Method migrate + * @method migrate + * + * @description + * Prepares legacy plans or explicitly activates their authority, preserving inline conflicts. + * + * @access public + * @since unreleased + * + * @type {RxMethod<{ organizationId: string; activate: boolean }>} + */ + migrate: rxMethod<{ organizationId: string; activate: boolean }>( + pipe( + exhaustMap(({ organizationId, activate }) => { + patchState(store, { migrationCallState: pendingCallState() }); + return ( + activate + ? service.activateEngine(organizationId) + : service.prepareLegacy(organizationId) + ).pipe( + tapResponse({ + next: (result) => { + if (store.organizationId() === organizationId) + patchState(store, { + migrationCallState: successCallState(result), + engineCallState: successCallState(result), + }); + dispatcher.dispatch(maintenancePlansStoreEvents.planChanged({ organizationId })); + }, + error: (error: unknown) => { + if (store.organizationId() === organizationId) + patchState(store, { migrationCallState: errorCallState(toStoreError(error)) }); + }, + }), + ); + }), + ), + ), + /** + * Method resetCreate + * @method resetCreate + * + * @description + * Starts a fresh preparation attempt after the operator explicitly reopens the form. + * + * @access public + * @since unreleased + * + * @returns {void} Clears the completed create action. + */ + resetCreate(): void { + if (store.createCallState().status !== 'pending') + patchState(store, { createCallState: idleCallState() }); + }, + }), + ), +); + +/** + * Type MaintenancePlansStoreType + * + * @description + * Instance type of the page-scoped operation plan store. + * + * @type MaintenancePlansStoreType + */ +export type MaintenancePlansStoreType = InstanceType; diff --git a/src/app/features/organization/features/maintenance-schedules/state/maintenance-plans/models/state.interface.ts b/src/app/features/organization/features/maintenance-schedules/state/maintenance-plans/models/state.interface.ts new file mode 100644 index 000000000..0fd475c66 --- /dev/null +++ b/src/app/features/organization/features/maintenance-schedules/state/maintenance-plans/models/state.interface.ts @@ -0,0 +1,162 @@ +import type { CallState } from '@core/request-state'; +import type { EquipmentOutput } from '@features/organization/features/equipments/models'; +import type { + MaintenanceEngineOutput, + MaintenancePlanOutput, + MaintenancePlanPreviewOutput, + MaintenancePlanGenerationOutput, +} from '@features/organization/features/maintenance-schedules/models'; + +/** + * Interface MaintenancePlansState + * @interface MaintenancePlansState + * + * @description + * Independent reads and commands scoped to the currently displayed organization. + */ +export interface MaintenancePlansState { + /** + * Property organizationId + * @readonly + * + * @description + * Displayed organization. + * + * @since unreleased + * + * @type {string} + */ + readonly organizationId: string; + /** + * Property totalPlans + * @readonly + * + * @description + * Exact server total for the active query. + * + * @since unreleased + * + * @type {number} + */ + readonly totalPlans: number; + /** + * Property listCallState + * @readonly + * + * @description + * Plan list request. + * + * @since unreleased + * + * @type {CallState} + */ + readonly listCallState: CallState; + /** + * Property engineCallState + * @readonly + * + * @description + * Scheduling authority request. + * + * @since unreleased + * + * @type {CallState} + */ + readonly engineCallState: CallState; + /** + * Property migrationCallState + * @readonly + * + * @description + * Preparation or authority-switch command. + * + * @since unreleased + * + * @type {CallState} + */ + readonly migrationCallState: CallState; + /** + * Property createCallState + * @readonly + * + * @description + * Prepared plan command. + * + * @since unreleased + * + * @type {CallState} + */ + readonly createCallState: CallState; + /** + * Property updateCallState + * @readonly + * + * @description + * Plan configuration command. + * + * @since unreleased + * + * @type {CallState} + */ + readonly updateCallState: CallState; + /** + * Property previewCallState + * @readonly + * + * @description + * Server date preview. + * + * @since unreleased + * + * @type {CallState} + */ + readonly previewCallState: CallState; + /** + * Property generationCallState + * @readonly + * + * @description + * Bounded generation or new-attempt command. + * + * @since unreleased + * + * @type {CallState} + */ + readonly generationCallState: CallState; + /** + * Property selectedPlan + * @readonly + * + * @description + * Plan whose calendar is under review. + * + * @since unreleased + * + * @type {MaintenancePlanOutput | null} + */ + readonly selectedPlan: MaintenancePlanOutput | null; + /** + * Property equipmentCallState + * @readonly + * + * @description + * Server page of authorized equipment choices. + * + * @since unreleased + * + * @type {CallState} + */ + readonly equipmentCallState: CallState; + /** + * Property totalEquipment + * @readonly + * + * @description + * Exact equipment search total. + * + * @since unreleased + * + * @type {number} + */ + readonly totalEquipment: number; +} diff --git a/src/app/features/organization/features/maintenance-schedules/state/maintenance-plans/testing/maintenance-plans.store.spec.ts b/src/app/features/organization/features/maintenance-schedules/state/maintenance-plans/testing/maintenance-plans.store.spec.ts new file mode 100644 index 000000000..ad081a893 --- /dev/null +++ b/src/app/features/organization/features/maintenance-schedules/state/maintenance-plans/testing/maintenance-plans.store.spec.ts @@ -0,0 +1,154 @@ +import { TestBed } from '@angular/core/testing'; +import { Dispatcher } from '@ngrx/signals/events'; +import { of, Subject, throwError } from 'rxjs'; +import type { HydraCollection } from '@core/api/models'; +import { EquipmentService } from '@features/organization/features/equipments/data-access'; +import { MaintenancePlanService } from '@features/organization/features/maintenance-schedules/data-access'; +import type { + MaintenancePlanOutput, + MaintenancePlanPreviewOutput, + MaintenancePlanGenerationOutput, +} from '@features/organization/features/maintenance-schedules/models'; +import { MaintenancePlansStore } from '../maintenance-plans.store'; + +describe('MaintenancePlansStore', () => { + let store: InstanceType; + let service: { + list: ReturnType; + engine: ReturnType; + create: ReturnType; + update: ReturnType; + preview: ReturnType; + generate: ReturnType; + prepareLegacy: ReturnType; + activateEngine: ReturnType; + }; + const plan: MaintenancePlanOutput = { + '@id': '/api/organizations/org-1/maintenance/plans/plan-1', + '@type': 'MaintenancePlan', + id: 'plan-1', + organizationId: 'org-1', + equipmentId: 'equipment-1', + facilityId: 'facility-1', + equipmentType: 'fire_extinguisher', + name: 'Annual control', + operationKind: 'control', + interval: 'P1Y', + cadenceMode: 'fixed', + anchorAt: '2026-01-31T00:00:00Z', + nextDueAt: '2027-01-31T00:00:00Z', + active: false, + legacyScheduleId: null, + lastCompletedAt: null, + openOccurrence: null, + }; + const collection: HydraCollection = { + '@id': '/api/plans', + '@type': 'Collection', + member: [plan], + totalItems: 1, + }; + const work: MaintenancePlanGenerationOutput = { + occurrenceId: 'occurrence-1', + interventionId: 'work-1', + number: 1, + workItemsCount: 1, + replayed: false, + }; + + beforeEach(() => { + service = { + list: vi.fn().mockReturnValue(of(collection)), + engine: vi.fn().mockReturnValue(of({ mode: 'legacy', preparedCount: 0 })), + create: vi.fn().mockReturnValue(of(plan)), + update: vi.fn().mockReturnValue(of({ ...plan, active: true })), + preview: vi.fn().mockReturnValue(of({ dates: ['2027-01-31', '2028-01-31', '2029-01-31'] })), + generate: vi.fn().mockReturnValue(of(work)), + prepareLegacy: vi.fn().mockReturnValue(of({ mode: 'legacy', preparedCount: 3 })), + activateEngine: vi.fn().mockReturnValue(of({ mode: 'plans', preparedCount: 3 })), + }; + TestBed.configureTestingModule({ + providers: [ + MaintenancePlansStore, + { provide: MaintenancePlanService, useValue: service }, + { + provide: EquipmentService, + useValue: { list: vi.fn().mockReturnValue(of({ member: [], totalItems: 0 })) }, + }, + { provide: Dispatcher, useValue: { dispatch: vi.fn() } }, + ], + }); + store = TestBed.inject(MaintenancePlansStore); + store.setScope('org-1'); + }); + + it('retains server totals and cancels an obsolete operation-kind query', () => { + const obsolete = new Subject>(); + service.list.mockReturnValueOnce(obsolete); + store.load({ organizationId: 'org-1', options: { params: { operationKind: 'control' } } }); + store.load({ organizationId: 'org-1', options: { params: { operationKind: 'maintenance' } } }); + obsolete.next({ ...collection, member: [{ ...plan, name: 'Obsolete' }], totalItems: 20 }); + expect(store.planEntities()[0].name).toBe('Annual control'); + expect(store.totalPlans()).toBe(1); + }); + + it('never lets the preview of an old selection replace the current calendar', () => { + const obsolete = new Subject(); + service.preview.mockReturnValueOnce(obsolete); + store.preview(plan); + store.preview({ ...plan, id: 'plan-2' }); + obsolete.next({ dates: ['1900-01-01'] }); + expect(store.selectedPlan()?.id).toBe('plan-2'); + expect(store.previewCallState().data?.dates).toEqual([ + '2027-01-31', + '2028-01-31', + '2029-01-31', + ]); + }); + + it('keeps a rejected activation inline and does not claim completion', () => { + service.update.mockReturnValue( + throwError(() => ({ status: 422, title: 'Conflict', detail: 'Set the first due date.' })), + ); + store.preview(plan); + store.update({ organizationId: 'org-1', planId: 'plan-1', input: { active: true } }); + expect(store.updateCallState().status).toBe('error'); + expect(store.selectedPlan()?.active).toBe(false); + expect(store.selectedPlan()?.lastCompletedAt).toBeNull(); + }); + + it('does not cancel accepted work generation on a double click', () => { + const accepted = new Subject(); + service.generate.mockReturnValue(accepted); + store.generate({ organizationId: 'org-1', planId: 'plan-1', retry: false }); + store.generate({ organizationId: 'org-1', planId: 'plan-1', retry: true }); + expect(service.generate).toHaveBeenCalledTimes(1); + expect(store.generationCallState().status).toBe('pending'); + accepted.next(work); + accepted.complete(); + expect(store.generationCallState().data?.occurrenceId).toBe('occurrence-1'); + }); + + it('preserves an accepted write but ignores its late response after organization changes', () => { + const accepted = new Subject(); + service.update.mockReturnValue(accepted); + store.update({ organizationId: 'org-1', planId: 'plan-1', input: { active: true } }); + store.setScope('org-2'); + accepted.next({ ...plan, active: true }); + expect(store.organizationId()).toBe('org-2'); + expect(store.planEntities()).toEqual([]); + expect(store.updateCallState().status).toBe('idle'); + }); + + it('keeps legacy authority after preparation or a failed switch', () => { + store.loadEngine('org-1'); + store.migrate({ organizationId: 'org-1', activate: false }); + expect(store.engineCallState().data?.mode).toBe('legacy'); + service.activateEngine.mockReturnValue( + throwError(() => ({ status: 409, title: 'Conflict', detail: 'Ambiguous historical work.' })), + ); + store.migrate({ organizationId: 'org-1', activate: true }); + expect(store.engineCallState().data?.mode).toBe('legacy'); + expect(store.migrationCallState().status).toBe('error'); + }); +}); diff --git a/src/app/features/organization/features/maintenance-schedules/state/maintenance-schedules/maintenance-schedules.store.ts b/src/app/features/organization/features/maintenance-schedules/state/maintenance-schedules/maintenance-schedules.store.ts index ee1d55541..18ec53a74 100644 --- a/src/app/features/organization/features/maintenance-schedules/state/maintenance-schedules/maintenance-schedules.store.ts +++ b/src/app/features/organization/features/maintenance-schedules/state/maintenance-schedules/maintenance-schedules.store.ts @@ -1,10 +1,10 @@ import { computed, inject } from '@angular/core'; import { tapResponse } from '@ngrx/operators'; import { patchState, signalStore, type, withComputed, withMethods, withState } from '@ngrx/signals'; -import { setAllEntities, setEntity, withEntities } from '@ngrx/signals/entities'; +import { removeAllEntities, setAllEntities, setEntity, withEntities } from '@ngrx/signals/entities'; import { Dispatcher } from '@ngrx/signals/events'; import { rxMethod } from '@ngrx/signals/rxjs-interop'; -import { exhaustMap, pipe, switchMap, tap } from 'rxjs'; +import { EMPTY, finalize, map, mergeMap, pipe, switchMap, tap } from 'rxjs'; import type { HydraCollection } from '@core/api/models'; import { errorCallState, @@ -39,6 +39,9 @@ import type { MaintenanceSchedulesState } from './models'; * @constant INITIAL_STATE */ const INITIAL_STATE: MaintenanceSchedulesState = { + organization: null, + scopeGeneration: 0, + campaignResultOrganization: null, listCallState: idleCallState(), totalSchedules: 0, overrideCallState: idleCallState(), @@ -145,171 +148,311 @@ export const MaintenanceSchedulesStore = signalStore( store, maintenanceScheduleService: MaintenanceScheduleService = inject(MaintenanceScheduleService), dispatcher: Dispatcher = inject(Dispatcher), - ) => ({ + ) => { /** - * Method load - * @method load + * Constant overrideWrites * * @description - * Fetches one page of schedules. `switchMap` cancels any in-flight - * request, so a fast filter change never races an older response. + * Tracks accepted override subscriptions by organization across scope changes. * - * @since 1.0.0 - * - * @type {RxMethod} + * @type {Set} */ - load: rxMethod( - pipe( - tap((): void => { - patchState(store, { listCallState: pendingCallState() }); - }), - switchMap((options) => - maintenanceScheduleService.list(options).pipe( - tapResponse({ - next: (response: HydraCollection): void => { - patchState( - store, - setAllEntities([...response.member], { collection: 'schedule' }), - { - totalSchedules: response.totalItems, - listCallState: successCallState(null), - }, - ); - }, - error: (error: unknown): void => { - const storeError: StoreError = toStoreError(error); - patchState(store, { listCallState: errorCallState(storeError) }); - dispatcher.dispatch( - maintenanceSchedulesStoreEvents.listFailed( - toStoreFailureEventPayload( - storeError, - 'Failed to load maintenance schedules', - ), - ), - ); - }, - }), - ), - ), - ), - ), + const overrideWrites = new Set(); /** - * Method setIntervalOverride - * @method setIntervalOverride + * Constant campaignWrites * * @description - * Sets or clears one schedule's interval override. `exhaustMap` - * prevents a concurrent submission. On success the response — the full - * recomputed schedule — replaces the entity directly; no refetch. - * - * @since 1.0.0 + * Tracks accepted campaign subscriptions by organization across scope changes. * - * @type {RxMethod<{ scheduleId: string; intervalOverride: string | null }>} + * @type {Set} */ - setIntervalOverride: rxMethod<{ scheduleId: string; intervalOverride: string | null }>( - pipe( - tap((): void => { - patchState(store, { overrideCallState: pendingCallState() }); - }), - exhaustMap(({ scheduleId, intervalOverride }) => - maintenanceScheduleService.setIntervalOverride(scheduleId, intervalOverride).pipe( - tapResponse({ - next: (schedule: MaintenanceScheduleOutput): void => { - patchState(store, setEntity(schedule, { collection: 'schedule' }), { - overrideCallState: successCallState(schedule), - }); - }, - error: (error: unknown): void => { - const storeError: StoreError = toStoreError(error); - patchState(store, { overrideCallState: errorCallState(storeError) }); - dispatcher.dispatch( - maintenanceSchedulesStoreEvents.overrideFailed( - toStoreFailureEventPayload( - storeError, - 'Failed to update the interval override', - ), - ), - ); - }, - }), - ), - ), - ), - ), + const campaignWrites = new Set(); /** - * Method generateCampaign - * @method generateCampaign + * Function setOrganization * * @description - * Generates an inspection campaign from the schedules matching the - * given scope. `exhaustMap` prevents a concurrent submission. Every - * failure — including the documented 422 no-match outcome — stays in - * `campaignError` for the dialog to render inline; nothing is - * dispatched as a toast (`events.ts`). + * Clears the previous organization's rows and action results before a new query starts. + * Returning to an organization with an accepted write preserves its pending indicator. * - * @since 1.0.0 + * @param {string} organization - Canonical organization IRI. * - * @type {RxMethod} + * @returns {void} */ - generateCampaign: rxMethod( - pipe( - tap((): void => { - patchState(store, { campaignCallState: pendingCallState() }); - }), - exhaustMap((input) => - maintenanceScheduleService.generateCampaign(input).pipe( - tapResponse({ - next: (result: MaintenanceCampaignOutput): void => { - patchState(store, { campaignCallState: successCallState(result) }); - dispatcher.dispatch( - maintenanceSchedulesStoreEvents.campaignSucceeded( - successFeedback( - $localize`:@@maintenance.campaign.toast.created:Campaign #${result.number}:number: created with ${result.workItemsCount}:count: work item(s)`, + const setOrganization = (organization: string): void => { + if (store.organization() === organization) return; + patchState(store, removeAllEntities({ collection: 'schedule' }), { + ...INITIAL_STATE, + organization, + scopeGeneration: store.scopeGeneration() + 1, + overrideCallState: overrideWrites.has(organization) + ? pendingCallState() + : idleCallState(), + campaignCallState: campaignWrites.has(organization) + ? pendingCallState() + : idleCallState(), + }); + }; + return { + setOrganization, + /** + * Method load + * @method load + * + * @description + * Fetches one page of schedules. `switchMap` cancels any in-flight + * request, so a fast filter change never races an older response. + * + * @since 1.0.0 + * + * @type {RxMethod} + */ + load: rxMethod( + pipe( + tap((options): void => { + setOrganization(options.organization); + patchState(store, { listCallState: pendingCallState() }); + }), + map((options) => ({ options, scopeGeneration: store.scopeGeneration() })), + switchMap(({ options, scopeGeneration }) => + maintenanceScheduleService.list(options).pipe( + tapResponse({ + next: (response: HydraCollection): void => { + if ( + store.organization() !== options.organization || + store.scopeGeneration() !== scopeGeneration + ) + return; + patchState( + store, + setAllEntities([...response.member], { collection: 'schedule' }), + { + totalSchedules: response.totalItems, + listCallState: successCallState(null), + }, + ); + }, + error: (error: unknown): void => { + if ( + store.organization() !== options.organization || + store.scopeGeneration() !== scopeGeneration + ) + return; + const storeError: StoreError = toStoreError(error); + patchState(store, { listCallState: errorCallState(storeError) }); + dispatcher.dispatch( + maintenanceSchedulesStoreEvents.listFailed( + toStoreFailureEventPayload( + storeError, + 'Failed to load maintenance schedules', + ), ), - ), - ); - }, - error: (error: unknown): void => { - patchState(store, { campaignCallState: errorCallState(toStoreError(error)) }); - }, - }), + ); + }, + }), + ), ), ), ), - ), - /** - * Method resetOverrideOperation - * - * @description - * Resets the override operation back to idle, for the dialog's close/reopen. - * - * @access public - * @since 1.0.0 - * - * @returns {void} - */ - resetOverrideOperation(): void { - patchState(store, { overrideCallState: idleCallState() }); - }, + /** + * Method setIntervalOverride + * @method setIntervalOverride + * + * @description + * Sets or clears one schedule's interval override. Each organization admits one + * override at a time; accepted writes continue across scope changes. On success the + * full recomputed schedule replaces the entity directly, without a refetch. + * + * @since 1.0.0 + * + * @type {RxMethod<{ + * organization: string; + * scheduleId: string; + * intervalOverride: string | null; + * }>} + */ + setIntervalOverride: rxMethod<{ + organization: string; + scheduleId: string; + intervalOverride: string | null; + }>( + pipe( + mergeMap(({ organization, scheduleId, intervalOverride }) => { + if (store.organization() === null) setOrganization(organization); + if (store.organization() !== organization || overrideWrites.has(organization)) + return EMPTY; + const scopeGeneration = store.scopeGeneration(); + const cached = store.scheduleEntityMap()[scheduleId]; + if (cached && cached.organization !== organization) return EMPTY; + overrideWrites.add(organization); + patchState(store, { overrideCallState: pendingCallState() }); + return maintenanceScheduleService + .setIntervalOverride(scheduleId, intervalOverride) + .pipe( + tapResponse({ + next: (schedule: MaintenanceScheduleOutput): void => { + if ( + store.organization() !== organization || + store.scopeGeneration() !== scopeGeneration || + schedule.organization !== organization + ) + return; + patchState(store, setEntity(schedule, { collection: 'schedule' }), { + overrideCallState: successCallState(schedule), + }); + }, + error: (error: unknown): void => { + if ( + store.organization() !== organization || + store.scopeGeneration() !== scopeGeneration + ) + return; + const storeError: StoreError = toStoreError(error); + patchState(store, { overrideCallState: errorCallState(storeError) }); + dispatcher.dispatch( + maintenanceSchedulesStoreEvents.overrideFailed( + toStoreFailureEventPayload( + storeError, + 'Failed to update the interval override', + ), + ), + ); + }, + }), + finalize(() => { + overrideWrites.delete(organization); + if ( + store.organization() === organization && + (store.scopeGeneration() !== scopeGeneration || + store.overrideCallState().status === 'pending') + ) + patchState(store, { overrideCallState: idleCallState() }); + }), + ); + }), + ), + ), - /** - * Method resetCampaignOperation - * - * @description - * Resets the campaign operation back to idle, for the dialog's close/reopen. - * - * @access public - * @since 1.0.0 - * - * @returns {void} - */ - resetCampaignOperation(): void { - patchState(store, { campaignCallState: idleCallState() }); - }, - }), + /** + * Method generateCampaign + * @method generateCampaign + * + * @description + * Generates an inspection campaign from the schedules matching the given scope. Each + * organization admits one campaign at a time while accepted writes continue across scope + * changes. Every failure — including the 422 no-match outcome — stays in `campaignError` + * for the dialog to render inline; nothing is dispatched as a toast (`events.ts`). + * + * @since 1.0.0 + * + * @type {RxMethod} + */ + generateCampaign: rxMethod( + pipe( + mergeMap((input) => { + if (store.organization() === null) setOrganization(input.organization); + if ( + store.organization() !== input.organization || + campaignWrites.has(input.organization) + ) + return EMPTY; + const scopeGeneration = store.scopeGeneration(); + campaignWrites.add(input.organization); + patchState(store, { + campaignCallState: pendingCallState(), + campaignResultOrganization: null, + }); + return maintenanceScheduleService.generateCampaign(input).pipe( + tapResponse({ + next: (result: MaintenanceCampaignOutput): void => { + if ( + store.organization() !== input.organization || + store.scopeGeneration() !== scopeGeneration + ) + return; + patchState(store, { + campaignCallState: successCallState(result), + campaignResultOrganization: input.organization, + }); + dispatcher.dispatch( + maintenanceSchedulesStoreEvents.campaignSucceeded( + successFeedback( + $localize`:@@maintenance.campaign.toast.created:Campaign #${result.number}:number: created with ${result.workItemsCount}:count: work item(s)`, + ), + ), + ); + }, + error: (error: unknown): void => { + if ( + store.organization() !== input.organization || + store.scopeGeneration() !== scopeGeneration + ) + return; + patchState(store, { campaignCallState: errorCallState(toStoreError(error)) }); + }, + }), + finalize(() => { + campaignWrites.delete(input.organization); + if ( + store.organization() === input.organization && + (store.scopeGeneration() !== scopeGeneration || + store.campaignCallState().status === 'pending') + ) + patchState(store, { campaignCallState: idleCallState() }); + }), + ); + }), + ), + ), + + /** + * Method resetOverrideOperation + * + * @description + * Clears override feedback for the dialog's close/reopen, preserving accepted work as + * pending. + * + * @access public + * @since 1.0.0 + * + * @returns {void} + */ + resetOverrideOperation(): void { + const organization = store.organization(); + patchState(store, { + overrideCallState: + organization !== null && overrideWrites.has(organization) + ? pendingCallState() + : idleCallState(), + }); + }, + + /** + * Method resetCampaignOperation + * + * @description + * Clears campaign feedback for the dialog's close/reopen, preserving accepted work as + * pending. + * + * @access public + * @since 1.0.0 + * + * @returns {void} + */ + resetCampaignOperation(): void { + const organization = store.organization(); + patchState(store, { + campaignCallState: + organization !== null && campaignWrites.has(organization) + ? pendingCallState() + : idleCallState(), + campaignResultOrganization: null, + }); + }, + }; + }, ), ); diff --git a/src/app/features/organization/features/maintenance-schedules/state/maintenance-schedules/models/state.interface.ts b/src/app/features/organization/features/maintenance-schedules/state/maintenance-schedules/models/state.interface.ts index b6bf89079..7e40679c4 100644 --- a/src/app/features/organization/features/maintenance-schedules/state/maintenance-schedules/models/state.interface.ts +++ b/src/app/features/organization/features/maintenance-schedules/state/maintenance-schedules/models/state.interface.ts @@ -16,12 +16,80 @@ import type { * @since 1.0.0 */ export interface MaintenanceSchedulesState { - /** @type {CallState} */ + /** + * Property organization + * @readonly + * + * @description + * Canonical organization IRI owning the visible historical schedules. + * + * @type {string | null} + */ + readonly organization: string | null; + + /** + * Property scopeGeneration + * @readonly + * + * @description + * Monotonic fence for responses from earlier organization visits. + * + * @type {number} + */ + readonly scopeGeneration: number; + + /** + * Property campaignResultOrganization + * @readonly + * + * @description + * Original organization IRI of the confirmed campaign used for navigation. + * + * @type {string | null} + */ + readonly campaignResultOrganization: string | null; + + /** + * Property listCallState + * @readonly + * + * @description + * Request state of the current organization's schedule query. + * + * @type {CallState} + */ readonly listCallState: CallState; - /** @type {number} */ + + /** + * Property totalSchedules + * @readonly + * + * @description + * Exact server total for the current query. + * + * @type {number} + */ readonly totalSchedules: number; - /** @type {CallState} */ + + /** + * Property overrideCallState + * @readonly + * + * @description + * Current organization's interval override result and request state. + * + * @type {CallState} + */ readonly overrideCallState: CallState; - /** @type {CallState} */ + + /** + * Property campaignCallState + * @readonly + * + * @description + * Current organization's campaign generation result and request state. + * + * @type {CallState} + */ readonly campaignCallState: CallState; } diff --git a/src/app/features/organization/features/maintenance-schedules/state/maintenance-schedules/testing/maintenance-schedules.store.spec.ts b/src/app/features/organization/features/maintenance-schedules/state/maintenance-schedules/testing/maintenance-schedules.store.spec.ts index 7218fa690..cb5d1238a 100644 --- a/src/app/features/organization/features/maintenance-schedules/state/maintenance-schedules/testing/maintenance-schedules.store.spec.ts +++ b/src/app/features/organization/features/maintenance-schedules/state/maintenance-schedules/testing/maintenance-schedules.store.spec.ts @@ -1,6 +1,6 @@ import { TestBed } from '@angular/core/testing'; import { Dispatcher } from '@ngrx/signals/events'; -import { of, throwError } from 'rxjs'; +import { of, Subject, throwError } from 'rxjs'; import type { HydraCollection } from '@core/api/models'; import { MaintenanceScheduleService } from '@features/organization/features/maintenance-schedules/data-access'; import type { @@ -24,6 +24,7 @@ describe('MaintenanceSchedulesStore', () => { let mockDispatcher: { dispatch: ReturnType }; const orgIri = '/api/organizations/org-1'; + const otherOrgIri = '/api/organizations/org-2'; const schedule: MaintenanceScheduleOutput = { '@id': '/api/maintenance/schedules/schedule-1', @@ -44,6 +45,18 @@ describe('MaintenanceSchedulesStore', () => { member: [schedule], }; + const otherSchedule: MaintenanceScheduleOutput = { + ...schedule, + '@id': '/api/maintenance/schedules/schedule-2', + id: 'schedule-2', + organization: otherOrgIri, + }; + + const otherCollection: HydraCollection = { + ...collection, + member: [otherSchedule], + }; + const campaignResult: MaintenanceCampaignOutput = { '@id': '', '@type': 'MaintenanceCampaignResult', @@ -93,6 +106,42 @@ describe('MaintenanceSchedulesStore', () => { expect.objectContaining({ type: '[Maintenance Schedules Store] listFailed' }), ); }); + + it('clears the previous organization rows before the next list response', () => { + store.load({ organization: orgIri }); + const response = new Subject>(); + mockService.list.mockReturnValue(response); + + store.load({ organization: otherOrgIri }); + + expect(store.organization()).toBe(otherOrgIri); + expect(store.isLoading()).toBe(true); + expect(store.schedules()).toEqual([]); + expect(store.totalSchedules()).toBe(0); + + response.next(otherCollection); + response.complete(); + + expect(store.schedules()).toEqual([otherSchedule]); + expect(store.isLoading()).toBe(false); + }); + + it('retains current organization rows during a same-organization refresh', () => { + store.load({ organization: orgIri }); + const response = new Subject>(); + mockService.list.mockReturnValue(response); + + store.load({ organization: orgIri }); + + expect(store.isLoading()).toBe(true); + expect(store.schedules()).toEqual([schedule]); + expect(store.totalSchedules()).toBe(1); + + response.next({ ...collection, member: [{ ...schedule, dueStatus: 'overdue' }] }); + response.complete(); + + expect(store.schedules()).toEqual([{ ...schedule, dueStatus: 'overdue' }]); + }); }); describe('setIntervalOverride', () => { @@ -100,7 +149,11 @@ describe('MaintenanceSchedulesStore', () => { store.load({ organization: orgIri }); await flushEffects(); - store.setIntervalOverride({ scheduleId: 'schedule-1', intervalOverride: 'P6M' }); + store.setIntervalOverride({ + organization: orgIri, + scheduleId: 'schedule-1', + intervalOverride: 'P6M', + }); await flushEffects(); expect(mockService.setIntervalOverride).toHaveBeenCalledWith('schedule-1', 'P6M'); @@ -110,7 +163,11 @@ describe('MaintenanceSchedulesStore', () => { }); it('should send an explicit null to clear the override', async () => { - store.setIntervalOverride({ scheduleId: 'schedule-1', intervalOverride: null }); + store.setIntervalOverride({ + organization: orgIri, + scheduleId: 'schedule-1', + intervalOverride: null, + }); await flushEffects(); expect(mockService.setIntervalOverride).toHaveBeenCalledWith('schedule-1', null); @@ -121,13 +178,171 @@ describe('MaintenanceSchedulesStore', () => { throwError(() => ({ status: 422, title: 'Unprocessable Entity' })), ); - store.setIntervalOverride({ scheduleId: 'schedule-1', intervalOverride: 'P1D' }); + store.setIntervalOverride({ + organization: orgIri, + scheduleId: 'schedule-1', + intervalOverride: 'P1D', + }); await flushEffects(); expect(mockDispatcher.dispatch).toHaveBeenCalledWith( expect.objectContaining({ type: '[Maintenance Schedules Store] overrideFailed' }), ); }); + + it.each([false, true])( + 'ignores a late override after switching organization, including a return visit (%s)', + (returnToOriginal) => { + const response = new Subject(); + mockService.setIntervalOverride.mockReturnValue(response); + store.load({ organization: orgIri }); + store.setIntervalOverride({ + organization: orgIri, + scheduleId: schedule.id, + intervalOverride: 'P6M', + }); + mockService.list.mockReturnValue(of(otherCollection)); + store.load({ organization: otherOrgIri }); + + if (returnToOriginal) { + mockService.list.mockReturnValue(of(collection)); + store.load({ organization: orgIri }); + } + + expect(response.observed).toBe(true); + response.next({ ...schedule, intervalOverride: 'P6M' }); + response.complete(); + + expect(store.schedules()).toEqual(returnToOriginal ? [schedule] : [otherSchedule]); + expect(store.overrideCallState().status).toBe('idle'); + expect(mockDispatcher.dispatch).not.toHaveBeenCalled(); + }, + ); + + it('ignores a late override failure after switching organization', () => { + const response = new Subject(); + mockService.setIntervalOverride.mockReturnValue(response); + store.load({ organization: orgIri }); + store.setIntervalOverride({ + organization: orgIri, + scheduleId: schedule.id, + intervalOverride: 'P6M', + }); + mockService.list.mockReturnValue(of(otherCollection)); + store.load({ organization: otherOrgIri }); + + response.error({ status: 422, title: 'Unprocessable Entity' }); + + expect(store.schedules()).toEqual([otherSchedule]); + expect(store.overrideCallState().status).toBe('idle'); + expect(mockDispatcher.dispatch).not.toHaveBeenCalled(); + }); + + it('rejects an override command for the previous organization', () => { + store.setOrganization(otherOrgIri); + + store.setIntervalOverride({ + organization: orgIri, + scheduleId: schedule.id, + intervalOverride: 'P6M', + }); + + expect(mockService.setIntervalOverride).not.toHaveBeenCalled(); + expect(store.overrideCallState().status).toBe('idle'); + expect(store.schedules()).toEqual([]); + }); + + it.each([true, false])( + 'admits the new organization override while an accepted write remains in flight (old first: %s)', + (oldFirst) => { + const oldResponse = new Subject(); + const currentResponse = new Subject(); + const currentSchedule = { ...otherSchedule, intervalOverride: 'P3M' }; + mockService.setIntervalOverride + .mockReturnValueOnce(oldResponse) + .mockReturnValueOnce(currentResponse); + store.load({ organization: orgIri }); + store.setIntervalOverride({ + organization: orgIri, + scheduleId: schedule.id, + intervalOverride: 'P6M', + }); + mockService.list.mockReturnValue(of(otherCollection)); + store.load({ organization: otherOrgIri }); + expect(store.isOverriding()).toBe(false); + + store.setIntervalOverride({ + organization: otherOrgIri, + scheduleId: otherSchedule.id, + intervalOverride: 'P3M', + }); + + expect(mockService.setIntervalOverride).toHaveBeenCalledTimes(2); + expect(mockService.setIntervalOverride).toHaveBeenLastCalledWith(otherSchedule.id, 'P3M'); + expect(oldResponse.observed).toBe(true); + expect(currentResponse.observed).toBe(true); + expect(store.isOverriding()).toBe(true); + const finishOld = (): void => { + oldResponse.next({ ...schedule, intervalOverride: 'P6M' }); + oldResponse.complete(); + }; + + if (oldFirst) { + finishOld(); + expect(store.isOverriding()).toBe(true); + expect(store.schedules()).toEqual([otherSchedule]); + } + currentResponse.next(currentSchedule); + currentResponse.complete(); + if (!oldFirst) finishOld(); + + expect(store.schedules()).toEqual([currentSchedule]); + expect(store.overrideCallState().data).toEqual(currentSchedule); + expect(store.overrideCallState().status).toBe('success'); + expect(mockDispatcher.dispatch).not.toHaveBeenCalled(); + }, + ); + + it.each(['success', 'error'])( + 'blocks override duplicates across a return visit until the accepted write settles (%s)', + (outcome) => { + const response = new Subject(); + const command = { + organization: orgIri, + scheduleId: schedule.id, + intervalOverride: 'P6M', + }; + mockService.setIntervalOverride.mockReturnValueOnce(response); + store.load({ organization: orgIri }); + store.setIntervalOverride(command); + store.setIntervalOverride(command); + store.setOrganization(otherOrgIri); + store.load({ organization: orgIri }); + store.resetOverrideOperation(); + store.setIntervalOverride(command); + + expect(mockService.setIntervalOverride).toHaveBeenCalledTimes(1); + expect(response.observed).toBe(true); + expect(store.isOverriding()).toBe(true); + + if (outcome === 'success') { + response.next({ ...schedule, intervalOverride: 'P6M' }); + response.complete(); + } else { + response.error({ status: 422, title: 'Unprocessable Entity' }); + } + + expect(store.schedules()).toEqual([schedule]); + expect(store.overrideCallState().status).toBe('idle'); + expect(mockDispatcher.dispatch).not.toHaveBeenCalled(); + + store.setIntervalOverride(command); + + expect(mockService.setIntervalOverride).toHaveBeenCalledTimes(2); + expect(store.schedules()).toEqual([{ ...schedule, intervalOverride: 'P6M' }]); + expect(store.overrideCallState().status).toBe('success'); + }, + ); }); describe('generateCampaign', () => { @@ -142,6 +357,7 @@ describe('MaintenanceSchedulesStore', () => { await flushEffects(); expect(store.campaignResult()).toEqual(campaignResult); + expect(store.campaignResultOrganization()).toBe(orgIri); expect(store.campaignError()).toBeNull(); expect(mockDispatcher.dispatch).toHaveBeenCalledWith( expect.objectContaining({ type: '[Maintenance Schedules Store] campaignSucceeded' }), @@ -169,9 +385,180 @@ describe('MaintenanceSchedulesStore', () => { ); expect(mockDispatcher.dispatch).not.toHaveBeenCalled(); }); + + it.each([false, true])( + 'ignores a late campaign after switching organization, including a return visit (%s)', + (returnToOriginal) => { + const response = new Subject(); + mockService.generateCampaign.mockReturnValue(response); + store.load({ organization: orgIri }); + store.generateCampaign(input); + mockService.list.mockReturnValue(of(otherCollection)); + store.load({ organization: otherOrgIri }); + + if (returnToOriginal) { + mockService.list.mockReturnValue(of(collection)); + store.load({ organization: orgIri }); + } + + expect(response.observed).toBe(true); + response.next(campaignResult); + response.complete(); + + expect(store.schedules()).toEqual(returnToOriginal ? [schedule] : [otherSchedule]); + expect(store.campaignResult()).toBeNull(); + expect(store.campaignResultOrganization()).toBeNull(); + expect(store.campaignCallState().status).toBe('idle'); + expect(mockDispatcher.dispatch).not.toHaveBeenCalled(); + }, + ); + + it('ignores a late campaign failure after switching organization', () => { + const response = new Subject(); + mockService.generateCampaign.mockReturnValue(response); + store.generateCampaign(input); + store.setOrganization(otherOrgIri); + + response.error({ status: 422, title: 'Unprocessable Entity' }); + + expect(store.campaignCallState().status).toBe('idle'); + expect(store.campaignError()).toBeNull(); + expect(store.campaignResultOrganization()).toBeNull(); + }); + + it('rejects a campaign command for the previous organization', () => { + store.setOrganization(otherOrgIri); + + store.generateCampaign(input); + + expect(mockService.generateCampaign).not.toHaveBeenCalled(); + expect(store.campaignCallState().status).toBe('idle'); + expect(store.campaignResultOrganization()).toBeNull(); + }); + + it.each([true, false])( + 'admits the new organization campaign while an accepted write remains in flight (old first: %s)', + (oldFirst) => { + const oldResponse = new Subject(); + const currentResponse = new Subject(); + const currentInput = { ...input, organization: otherOrgIri }; + const currentResult = { ...campaignResult, interventionId: 'intervention-2', number: 43 }; + mockService.generateCampaign + .mockReturnValueOnce(oldResponse) + .mockReturnValueOnce(currentResponse); + store.generateCampaign(input); + store.setOrganization(otherOrgIri); + expect(store.isGeneratingCampaign()).toBe(false); + + store.generateCampaign(currentInput); + + expect(mockService.generateCampaign).toHaveBeenCalledTimes(2); + expect(mockService.generateCampaign).toHaveBeenLastCalledWith(currentInput); + expect(oldResponse.observed).toBe(true); + expect(currentResponse.observed).toBe(true); + expect(store.isGeneratingCampaign()).toBe(true); + const finishOld = (): void => { + oldResponse.next(campaignResult); + oldResponse.complete(); + }; + + if (oldFirst) { + finishOld(); + expect(store.isGeneratingCampaign()).toBe(true); + expect(store.campaignResult()).toBeNull(); + expect(mockDispatcher.dispatch).not.toHaveBeenCalled(); + } + currentResponse.next(currentResult); + currentResponse.complete(); + if (!oldFirst) finishOld(); + + expect(store.campaignResult()).toEqual(currentResult); + expect(store.campaignResultOrganization()).toBe(otherOrgIri); + expect(store.campaignCallState().status).toBe('success'); + expect(mockDispatcher.dispatch).toHaveBeenCalledTimes(1); + }, + ); + + it.each(['success', 'error'])( + 'blocks campaign duplicates across a return visit until the accepted write settles (%s)', + (outcome) => { + const response = new Subject(); + mockService.generateCampaign.mockReturnValueOnce(response); + store.generateCampaign(input); + store.generateCampaign(input); + store.setOrganization(otherOrgIri); + store.setOrganization(orgIri); + store.resetCampaignOperation(); + store.generateCampaign(input); + + expect(mockService.generateCampaign).toHaveBeenCalledTimes(1); + expect(response.observed).toBe(true); + expect(store.isGeneratingCampaign()).toBe(true); + + if (outcome === 'success') { + response.next(campaignResult); + response.complete(); + } else { + response.error({ status: 422, title: 'Unprocessable Entity' }); + } + + expect(store.campaignCallState().status).toBe('idle'); + expect(store.campaignResult()).toBeNull(); + expect(store.campaignResultOrganization()).toBeNull(); + expect(mockDispatcher.dispatch).not.toHaveBeenCalled(); + + store.generateCampaign(input); + + expect(mockService.generateCampaign).toHaveBeenCalledTimes(2); + expect(store.campaignResult()).toEqual(campaignResult); + expect(store.campaignResultOrganization()).toBe(orgIri); + expect(mockDispatcher.dispatch).toHaveBeenCalledTimes(1); + }, + ); }); describe('resetOverrideOperation / resetCampaignOperation', () => { + it('keeps an accepted override pending through feedback reset until the request completes', () => { + const response = new Subject(); + mockService.setIntervalOverride.mockReturnValue(response); + store.setIntervalOverride({ + organization: orgIri, + scheduleId: schedule.id, + intervalOverride: 'P6M', + }); + store.resetOverrideOperation(); + expect(store.isOverriding()).toBe(true); + + response.next({ ...schedule, intervalOverride: 'P6M' }); + store.resetOverrideOperation(); + expect(store.isOverriding()).toBe(true); + response.complete(); + + expect(store.overrideCallState().status).toBe('idle'); + expect(store.schedules()).toEqual([{ ...schedule, intervalOverride: 'P6M' }]); + }); + + it('keeps an accepted campaign pending through feedback reset until the request completes', () => { + const response = new Subject(); + mockService.generateCampaign.mockReturnValue(response); + store.generateCampaign({ + organization: orgIri, + name: 'Q1 round', + dueBefore: '2026-06-30T00:00:00+00:00', + }); + store.resetCampaignOperation(); + expect(store.isGeneratingCampaign()).toBe(true); + + response.next(campaignResult); + store.resetCampaignOperation(); + expect(store.isGeneratingCampaign()).toBe(true); + response.complete(); + + expect(store.campaignCallState().status).toBe('idle'); + expect(store.campaignResult()).toBeNull(); + expect(store.campaignResultOrganization()).toBeNull(); + }); + it('should return both operations to idle', async () => { store.generateCampaign({ organization: orgIri, @@ -184,8 +571,34 @@ describe('MaintenanceSchedulesStore', () => { store.resetOverrideOperation(); expect(store.campaignResult()).toBeNull(); + expect(store.campaignResultOrganization()).toBeNull(); expect(store.isGeneratingCampaign()).toBe(false); expect(store.isOverriding()).toBe(false); }); }); + + it('clears rows and all operation results on an organization change', () => { + store.load({ organization: orgIri }); + store.setIntervalOverride({ + organization: orgIri, + scheduleId: schedule.id, + intervalOverride: 'P6M', + }); + store.generateCampaign({ + organization: orgIri, + name: 'Q1 round', + dueBefore: '2026-06-30T00:00:00+00:00', + }); + + store.setOrganization(otherOrgIri); + + expect(store.organization()).toBe(otherOrgIri); + expect(store.schedules()).toEqual([]); + expect(store.totalSchedules()).toBe(0); + expect(store.listCallState().status).toBe('idle'); + expect(store.overrideCallState().status).toBe('idle'); + expect(store.campaignCallState().status).toBe('idle'); + expect(store.campaignResult()).toBeNull(); + expect(store.campaignResultOrganization()).toBeNull(); + }); }); diff --git a/src/app/features/organization/features/maintenance-schedules/ui/components/maintenance-plan-list/index.ts b/src/app/features/organization/features/maintenance-schedules/ui/components/maintenance-plan-list/index.ts new file mode 100644 index 000000000..8ddf1cc0b --- /dev/null +++ b/src/app/features/organization/features/maintenance-schedules/ui/components/maintenance-plan-list/index.ts @@ -0,0 +1 @@ +export { MaintenancePlanList } from './maintenance-plan-list.component'; diff --git a/src/app/features/organization/features/maintenance-schedules/ui/components/maintenance-plan-list/maintenance-plan-list.component.html b/src/app/features/organization/features/maintenance-schedules/ui/components/maintenance-plan-list/maintenance-plan-list.component.html new file mode 100644 index 000000000..bee920baa --- /dev/null +++ b/src/app/features/organization/features/maintenance-schedules/ui/components/maintenance-plan-list/maintenance-plan-list.component.html @@ -0,0 +1,115 @@ +
+ @for (plan of plans(); track plan.id; let last = $last) { +
+
+
+ + {{ operationLabel(plan) }} + @if (!plan.active) { + Prepared + } +
+
+ View equipment + {{ cadenceLabel(plan) }} · {{ durationLabel(plan.interval) }} + @if (plan.nextDueAt; as dueAt) { + Next due: + {{ dateValue(dueAt, plan) | appOrgDate: dateMode(plan) : regionalFormatting() }} + } @else { + First deadline to configure — no validated control recorded. + } + @if (plan.openOccurrence; as occurrence) { + Original deadline: + {{ + dateValue(occurrence.dueAt, plan) + | appOrgDate: dateMode(plan) : regionalFormatting() + }} + · attempt + {{ occurrence.attempt }} + @if (occurrence.interventionId; as interventionId) { + Open existing intervention + } + } +
+
+
+ + @if (canManage()) { + + } + @if (canGenerate() && plan.active) { + + @if (plan.openOccurrence?.retryAllowed) { + + } + } +
+
+ @if (!last) { +
+ } + } +
diff --git a/src/app/features/organization/features/maintenance-schedules/ui/components/maintenance-plan-list/maintenance-plan-list.component.ts b/src/app/features/organization/features/maintenance-schedules/ui/components/maintenance-plan-list/maintenance-plan-list.component.ts new file mode 100644 index 000000000..f6f570515 --- /dev/null +++ b/src/app/features/organization/features/maintenance-schedules/ui/components/maintenance-plan-list/maintenance-plan-list.component.ts @@ -0,0 +1,273 @@ +import { + ChangeDetectionStrategy, + Component, + LOCALE_ID, + inject, + input, + output, + type InputSignal, + type OutputEmitterRef, +} from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { Duration } from 'luxon'; +import type { MaintenancePlanOutput } from '@features/organization/features/maintenance-schedules/models'; +import { + maintenancePlanDateMode, + maintenancePlanDateValue, +} from '@features/organization/features/maintenance-schedules/utils'; +import { OrgDatePipe, type RegionalFormatSettings } from '@shared/regional-format'; +import { HlmBadge } from '@shared/ui/badge'; +import { HlmButton } from '@shared/ui/button'; +import { HlmItemImports } from '@shared/ui/item'; + +/** + * Class MaintenancePlanList + * @class MaintenancePlanList + * + * @description + * Flat equipment operation list; dates, open occurrences and retry permission remain server-owned. + */ +@Component({ + selector: 'app-maintenance-plan-list', + imports: [RouterLink, OrgDatePipe, HlmBadge, HlmButton, ...HlmItemImports], + templateUrl: './maintenance-plan-list.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class MaintenancePlanList { + //#region Properties + /** + * Property localeId + * @readonly + * + * @description + * Formats the configured interval in the active interface language. + * + * @access private + * @since unreleased + * + * @type {string} + */ + private readonly localeId: string = inject(LOCALE_ID); + + /** + * Property dateMode + * @readonly + * + * @description + * Keeps date-only calendar anchors independent from the viewer's timezone. + * + * @access protected + * @since unreleased + * + * @type {typeof maintenancePlanDateMode} + */ + protected readonly dateMode: typeof maintenancePlanDateMode = maintenancePlanDateMode; + + /** + * Property dateValue + * @readonly + * + * @description + * Supplies the server's calendar day to date-only formatting without converting its offset. + * + * @access protected + * @since unreleased + * + * @type {typeof maintenancePlanDateValue} + */ + protected readonly dateValue: typeof maintenancePlanDateValue = maintenancePlanDateValue; + + /** + * Property plans + * @readonly + * + * @description + * Current server-filtered page. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly plans: InputSignal = input< + readonly MaintenancePlanOutput[] + >([]); + + /** + * Property organizationId + * @readonly + * + * @description + * Organization for equipment and work links. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly organizationId: InputSignal = input.required(); + + /** + * Property regionalFormatting + * @readonly + * + * @description + * Organization timezone and date settings. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly regionalFormatting: InputSignal = + input.required(); + + /** + * Property canManage + * @readonly + * + * @description + * Server-permission projection controls configuration actions. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly canManage: InputSignal = input(false); + + /** + * Property canGenerate + * @readonly + * + * @description + * Requires planning permission and confirmed plan authority. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly canGenerate: InputSignal = input(false); + + /** + * Property pending + * @readonly + * + * @description + * Accepted command prevents double submission. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly pending: InputSignal = input(false); + + /** + * Property previewRequested + * @readonly + * + * @description + * Saved calendar chosen for review. + * + * @access public + * @since unreleased + * + * @type {OutputEmitterRef} + */ + public readonly previewRequested: OutputEmitterRef = + output(); + + /** + * Property editRequested + * @readonly + * + * @description + * Explicit configuration request. + * + * @access public + * @since unreleased + * + * @type {OutputEmitterRef} + */ + public readonly editRequested: OutputEmitterRef = + output(); + + /** + * Property generationRequested + * @readonly + * + * @description + * Work creation or recovery request. + * + * @access public + * @since unreleased + * + * @type {OutputEmitterRef<{ planId: string; retry: boolean }>} + */ + public readonly generationRequested: OutputEmitterRef<{ planId: string; retry: boolean }> = + output<{ planId: string; retry: boolean }>(); + //#endregion + + //#region Methods + /** + * Method durationLabel + * @method durationLabel + * + * @description + * Formats the configured duration without calculating occurrences or translating technical codes. + * + * @access protected + * @since unreleased + * + * @param {string} interval - Server-configured ISO duration. + * + * @returns {string} Calendar units in the current interface language. + */ + protected durationLabel(interval: string): string { + const duration: Duration = Duration.fromISO(interval, { locale: this.localeId }); + return duration.isValid ? duration.toHuman({ listStyle: 'long', unitDisplay: 'long' }) : '—'; + } + + /** + * Method operationLabel + * @method operationLabel + * + * @description + * Resolves control and maintenance without deriving operational or anomaly state. + * + * @access protected + * @since unreleased + * + * @param {MaintenancePlanOutput} plan - Server plan. + * + * @returns {string} Operation label. + */ + protected operationLabel(plan: MaintenancePlanOutput): string { + return plan.operationKind === 'control' + ? $localize`:@@maintenance.plans.control:Control` + : $localize`:@@maintenance.plans.maintenance:Maintenance`; + } + + /** + * Method cadenceLabel + * @method cadenceLabel + * + * @description + * Explicitly identifies preserved historical recurrence. + * + * @access protected + * @since unreleased + * + * @param {MaintenancePlanOutput} plan - Server cadence. + * + * @returns {string} Cadence label. + */ + protected cadenceLabel(plan: MaintenancePlanOutput): string { + return plan.cadenceMode === 'legacy' + ? $localize`:@@maintenance.plans.legacyCadence:Historical cadence retained` + : $localize`:@@maintenance.plans.fixedCadence:Anchored calendar`; + } + //#endregion +} diff --git a/src/app/features/organization/features/maintenance-schedules/ui/components/maintenance-plan-list/testing/maintenance-plan-list.component.spec.ts b/src/app/features/organization/features/maintenance-schedules/ui/components/maintenance-plan-list/testing/maintenance-plan-list.component.spec.ts new file mode 100644 index 000000000..b99907e88 --- /dev/null +++ b/src/app/features/organization/features/maintenance-schedules/ui/components/maintenance-plan-list/testing/maintenance-plan-list.component.spec.ts @@ -0,0 +1,122 @@ +import { TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import type { MaintenancePlanOutput } from '@features/organization/features/maintenance-schedules/models'; +import { MaintenancePlanList } from '../maintenance-plan-list.component'; + +describe('MaintenancePlanList', () => { + const plan: MaintenancePlanOutput = { + '@id': '/api/plans/plan-1', + '@type': 'MaintenancePlan', + id: 'plan-1', + organizationId: 'org-1', + equipmentId: 'equipment-1', + equipmentType: 'fire_extinguisher', + name: 'Annual control', + operationKind: 'control', + interval: 'P1Y', + cadenceMode: 'legacy', + anchorAt: null, + nextDueAt: null, + active: true, + openOccurrence: { + id: 'occurrence-1', + dueAt: '2026-01-01T00:00:00Z', + attempt: 2, + interventionId: 'work-1', + status: 'open', + retryAllowed: false, + }, + }; + beforeEach(() => + TestBed.configureTestingModule({ + imports: [MaintenancePlanList], + providers: [provideRouter([])], + }), + ); + + it('retains original deadlines, attempts and historical cadence without claiming completion', () => { + const fixture = TestBed.createComponent(MaintenancePlanList); + fixture.componentRef.setInput('organizationId', 'org-1'); + fixture.componentRef.setInput('regionalFormatting', { + dateFormat: 'yyyy-MM-dd', + timezone: 'UTC', + }); + fixture.componentRef.setInput('plans', [plan]); + fixture.detectChanges(); + const root: HTMLElement = fixture.nativeElement as HTMLElement; + expect(root.textContent).toContain('Historical cadence retained'); + expect(root.textContent).toContain('Original deadline: 2026-01-01'); + expect(root.textContent).toContain('attempt 2'); + expect(root.querySelector('a')?.getAttribute('href')).toBe( + '/organizations/org-1/equipments/equipment-1', + ); + expect( + root.querySelector('a[href="/organizations/org-1/interventions/work-1"]'), + ).not.toBeNull(); + expect(root.textContent).not.toContain('Completed'); + }); + + it('offers a new attempt only when the server allows it and both actions are authorized', () => { + const fixture = TestBed.createComponent(MaintenancePlanList); + fixture.componentRef.setInput('organizationId', 'org-1'); + fixture.componentRef.setInput('regionalFormatting', { + dateFormat: 'yyyy-MM-dd', + timezone: 'UTC', + }); + fixture.componentRef.setInput('plans', [plan]); + fixture.componentRef.setInput('canManage', true); + fixture.componentRef.setInput('canGenerate', true); + fixture.detectChanges(); + expect((fixture.nativeElement as HTMLElement).textContent).not.toContain('Start a new attempt'); + fixture.componentRef.setInput('plans', [ + { ...plan, openOccurrence: { ...plan.openOccurrence, retryAllowed: true } }, + ]); + fixture.detectChanges(); + expect((fixture.nativeElement as HTMLElement).textContent).toContain('Start a new attempt'); + fixture.componentRef.setInput('canGenerate', false); + fixture.detectChanges(); + expect((fixture.nativeElement as HTMLElement).textContent).not.toContain( + 'Prepare intervention', + ); + expect((fixture.nativeElement as HTMLElement).textContent).not.toContain('Start a new attempt'); + }); + it.each(['2026-03-31T00:00:00Z', '2026-03-31T00:00:00+02:00', '2026-03-31T00:00:00-07:00'])( + 'keeps fixed calendar deadline %s on its server date in a negative viewer timezone', + (date) => { + const fixture = TestBed.createComponent(MaintenancePlanList); + fixture.componentRef.setInput('organizationId', 'org-1'); + fixture.componentRef.setInput('regionalFormatting', { + dateFormat: 'yyyy-MM-dd', + timezone: 'America/Los_Angeles', + }); + fixture.componentRef.setInput('plans', [ + { + ...plan, + cadenceMode: 'fixed', + nextDueAt: date, + openOccurrence: { ...plan.openOccurrence, dueAt: date }, + }, + ]); + fixture.detectChanges(); + expect((fixture.nativeElement as HTMLElement).textContent).toContain('Next due: 2026-03-31'); + expect((fixture.nativeElement as HTMLElement).textContent).toContain( + 'Original deadline: 2026-03-31', + ); + expect((fixture.nativeElement as HTMLElement).textContent).not.toContain('2026-03-30'); + }, + ); + + it('continues to render historical deadlines as instants in the viewer organization timezone', () => { + const fixture = TestBed.createComponent(MaintenancePlanList); + fixture.componentRef.setInput('organizationId', 'org-1'); + fixture.componentRef.setInput('regionalFormatting', { + dateFormat: 'yyyy-MM-dd', + timezone: 'America/Los_Angeles', + }); + fixture.componentRef.setInput('plans', [ + { ...plan, nextDueAt: '2026-03-31T00:00:00+02:00', openOccurrence: null }, + ]); + fixture.detectChanges(); + expect((fixture.nativeElement as HTMLElement).textContent).toContain('Next due: 2026-03-30'); + }); +}); diff --git a/src/app/features/organization/features/maintenance-schedules/ui/dialogs/maintenance-campaign-dialog/maintenance-campaign-dialog.component.html b/src/app/features/organization/features/maintenance-schedules/ui/dialogs/maintenance-campaign-dialog/maintenance-campaign-dialog.component.html index aa77b8d60..c98163111 100644 --- a/src/app/features/organization/features/maintenance-schedules/ui/dialogs/maintenance-campaign-dialog/maintenance-campaign-dialog.component.html +++ b/src/app/features/organization/features/maintenance-schedules/ui/dialogs/maintenance-campaign-dialog/maintenance-campaign-dialog.component.html @@ -23,6 +23,7 @@

Generate inspection [pending]="pending()" [serverError]="serverError()" [facilityOptions]="facilityOptions()" + [equipmentTypeOptions]="equipmentTypeOptions()" [facilityPage]="facilityPage()" [facilityPageCount]="facilityPageCount()" [facilityCallState]="facilityCallState()" diff --git a/src/app/features/organization/features/maintenance-schedules/ui/dialogs/maintenance-campaign-dialog/maintenance-campaign-dialog.component.ts b/src/app/features/organization/features/maintenance-schedules/ui/dialogs/maintenance-campaign-dialog/maintenance-campaign-dialog.component.ts index f9caf702f..db8b7e9ff 100644 --- a/src/app/features/organization/features/maintenance-schedules/ui/dialogs/maintenance-campaign-dialog/maintenance-campaign-dialog.component.ts +++ b/src/app/features/organization/features/maintenance-schedules/ui/dialogs/maintenance-campaign-dialog/maintenance-campaign-dialog.component.ts @@ -10,6 +10,7 @@ import { } from '@angular/core'; import type { BrnDialogState } from '@spartan-ng/brain/dialog'; import { idleCallState, type CallState, type StoreError } from '@core/request-state'; +import type { EquipmentTypeOption } from '@features/organization/features/equipments'; import type { FacilityOption } from '@features/organization/features/facilities/models'; import type { GenerateMaintenanceCampaignInput } from '@features/organization/features/maintenance-schedules/models'; import { sheetSide } from '@shared/sheet-side'; @@ -54,6 +55,22 @@ export class MaintenanceCampaignDialog { */ protected readonly side: Signal<'right' | 'bottom'> = sheetSide(); //#region Inputs + /** + * Property equipmentTypeOptions + * @readonly + * + * @description + * Authorized server catalog, including custom type codes, forwarded to the campaign form. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly equipmentTypeOptions: InputSignal = input< + readonly EquipmentTypeOption[] + >([]); + /** * Property facilityPage * @readonly diff --git a/src/app/features/organization/features/maintenance-schedules/ui/forms/maintenance-campaign-form/maintenance-campaign-form.component.html b/src/app/features/organization/features/maintenance-schedules/ui/forms/maintenance-campaign-form/maintenance-campaign-form.component.html index 6c90771b8..60e60bf60 100644 --- a/src/app/features/organization/features/maintenance-schedules/ui/forms/maintenance-campaign-form/maintenance-campaign-form.component.html +++ b/src/app/features/organization/features/maintenance-schedules/ui/forms/maintenance-campaign-form/maintenance-campaign-form.component.html @@ -90,7 +90,7 @@ Every type - @for (option of equipmentTypeOptions; track option.value) { + @for (option of equipmentTypeOptions(); track option.value) { {{ option.label }} } diff --git a/src/app/features/organization/features/maintenance-schedules/ui/forms/maintenance-campaign-form/maintenance-campaign-form.component.ts b/src/app/features/organization/features/maintenance-schedules/ui/forms/maintenance-campaign-form/maintenance-campaign-form.component.ts index cf9223cf5..1414053d1 100644 --- a/src/app/features/organization/features/maintenance-schedules/ui/forms/maintenance-campaign-form/maintenance-campaign-form.component.ts +++ b/src/app/features/organization/features/maintenance-schedules/ui/forms/maintenance-campaign-form/maintenance-campaign-form.component.ts @@ -20,7 +20,7 @@ import { type FieldTree, } from '@angular/forms/signals'; import { idleCallState, type CallState, type StoreError } from '@core/request-state'; -import { EQUIPMENT_TYPE_OPTIONS } from '@features/organization/features/equipments'; +import type { EquipmentTypeOption } from '@features/organization/features/equipments'; import type { FacilityOption } from '@features/organization/features/facilities/models'; import { FacilityOptionPicker } from '@features/organization/features/facilities/ui/components'; import type { GenerateMaintenanceCampaignInput } from '@features/organization/features/maintenance-schedules/models'; @@ -247,14 +247,16 @@ export class MaintenanceCampaignForm { * @readonly * * @description - * The equipment-type choices offered, reused from the equipments feature's public catalog. + * Equipment-type choices supplied from the authorized server catalog, including custom codes. * - * @access protected + * @access public * @since unreleased * - * @type {typeof EQUIPMENT_TYPE_OPTIONS} + * @type {InputSignal} */ - protected readonly equipmentTypeOptions: typeof EQUIPMENT_TYPE_OPTIONS = EQUIPMENT_TYPE_OPTIONS; + public readonly equipmentTypeOptions: InputSignal = input< + readonly EquipmentTypeOption[] + >([]); /** * Property noScopeValue @@ -365,7 +367,7 @@ export class MaintenanceCampaignForm { } return ( - this.equipmentTypeOptions.find((option) => option.value === value)?.label ?? + this.equipmentTypeOptions().find((option) => option.value === value)?.label ?? $localize`:@@common.unknownType:Unknown type` ); }; diff --git a/src/app/features/organization/features/maintenance-schedules/ui/forms/maintenance-campaign-form/testing/maintenance-campaign-form.component.spec.ts b/src/app/features/organization/features/maintenance-schedules/ui/forms/maintenance-campaign-form/testing/maintenance-campaign-form.component.spec.ts index cfb07b0a5..3fc7c84a1 100644 --- a/src/app/features/organization/features/maintenance-schedules/ui/forms/maintenance-campaign-form/testing/maintenance-campaign-form.component.spec.ts +++ b/src/app/features/organization/features/maintenance-schedules/ui/forms/maintenance-campaign-form/testing/maintenance-campaign-form.component.spec.ts @@ -50,6 +50,33 @@ describe('MaintenanceCampaignForm', () => { expect(submitted[0].equipmentType).toBeUndefined(); expect(new Date(submitted[0].dueBefore).getUTCFullYear()).toBe(2026); }); + it('uses and submits a server-defined custom equipment type without a closed enum', async () => { + fixture.componentRef.setInput('equipmentTypeOptions', [ + { + '@id': '/api/types/custom_fire_panel', + '@type': 'EquipmentType', + value: 'custom_fire_panel', + label: 'North hall fire panel', + family: 'fire', + archived: false, + revision: 1, + icon: 'lucideBox', + }, + ]); + fixture.componentInstance['model'].update((draft) => ({ + ...draft, + name: 'Custom panel control', + dueBefore: '2027-01-31', + equipmentType: 'custom_fire_panel', + })); + await fixture.whenStable(); + expect(fixture.nativeElement.textContent).toContain('North hall fire panel'); + const emitted = vi.spyOn(fixture.componentInstance.submitted, 'emit'); + fixture.componentInstance['submit'](new Event('submit')); + expect(emitted).toHaveBeenCalledWith( + expect.objectContaining({ equipmentType: 'custom_fire_panel' }), + ); + }); it('should render the no-match 422 detail inline rather than a generic message', async () => { const error = { diff --git a/src/app/features/organization/features/maintenance-schedules/ui/forms/maintenance-plan-form/index.ts b/src/app/features/organization/features/maintenance-schedules/ui/forms/maintenance-plan-form/index.ts new file mode 100644 index 000000000..88b8c5095 --- /dev/null +++ b/src/app/features/organization/features/maintenance-schedules/ui/forms/maintenance-plan-form/index.ts @@ -0,0 +1 @@ +export { MaintenancePlanForm } from './maintenance-plan-form.component'; diff --git a/src/app/features/organization/features/maintenance-schedules/ui/forms/maintenance-plan-form/maintenance-plan-form.component.html b/src/app/features/organization/features/maintenance-schedules/ui/forms/maintenance-plan-form/maintenance-plan-form.component.html new file mode 100644 index 000000000..714c40a60 --- /dev/null +++ b/src/app/features/organization/features/maintenance-schedules/ui/forms/maintenance-plan-form/maintenance-plan-form.component.html @@ -0,0 +1,240 @@ +
+ + @if (serverMessage(); as message) { +
+

{{ message }}

+
+ } + @if (!initialPlan()) { + + + + + + No matching equipment loaded. +
+ @for (item of equipment(); track item.id) { + {{ + equipmentLabelOf(item.id) + }} + } +
+
+
+ @if (equipmentState().status === 'pending') { +

+ Loading equipment… +

+ } + @if (equipmentState().error; as error) { +

{{ error.message }}

+ + } + @if (equipmentPageCount() > 1) { +
+ + +
+ } + @if (planForm.equipmentId().touched() && planForm.equipmentId().invalid()) { + {{ planForm.equipmentId().errors()[0].message }} + } +
+ } + + + + @if (planForm.name().touched() && planForm.name().invalid()) { + {{ planForm.name().errors()[0].message }} + } + + @if (!initialPlan()) { + + + + + + @for (kind of kinds; track kind.value) { + {{ kind.label }} + } + + + + } + @if (calendarLocked()) { +

+ The calendar is locked while an occurrence is open. You can still change the operation name. +

+ } +
+ + + + @if (planForm.every().touched() && planForm.every().invalid()) { + {{ planForm.every().errors()[0].message }} + } + + + + + + + @for (unit of units; track unit.value) { + {{ unit.label }} + } + + + @if (planForm.unit().touched() && planForm.unit().invalid()) { + {{ planForm.unit().errors()[0].message }} + } + +
+
+ + + + @if (planForm.anchorDate().touched() && planForm.anchorDate().invalid()) { + {{ planForm.anchorDate().errors()[0].message }} + } + + + + +

+ Optional for a new plan; defaults to the anchor date. Historical equipment without a + recorded control needs an explicit first deadline. +

+
+
+

+ Choose your organization's frequency. A monthly date uses the last day of shorter months and + retains its original anchor. This configuration does not certify regulatory compliance. +

+
+ + +
+
+
diff --git a/src/app/features/organization/features/maintenance-schedules/ui/forms/maintenance-plan-form/maintenance-plan-form.component.ts b/src/app/features/organization/features/maintenance-schedules/ui/forms/maintenance-plan-form/maintenance-plan-form.component.ts new file mode 100644 index 000000000..fbbbfdab8 --- /dev/null +++ b/src/app/features/organization/features/maintenance-schedules/ui/forms/maintenance-plan-form/maintenance-plan-form.component.ts @@ -0,0 +1,489 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + input, + linkedSignal, + output, + signal, + type InputSignal, + type OutputEmitterRef, + type Signal, + type WritableSignal, +} from '@angular/core'; +import { + disabled, + form, + FormField, + maxLength, + min, + required, + validate, + type FieldTree, +} from '@angular/forms/signals'; +import { idleCallState, type CallState, type StoreError } from '@core/request-state'; +import { buildEquipmentTitle } from '@features/organization/features/equipments'; +import type { EquipmentOutput } from '@features/organization/features/equipments/models'; +import type { + CreateMaintenancePlanInput, + MaintenancePlanOutput, +} from '@features/organization/features/maintenance-schedules/models'; +import { HlmAlertImports } from '@shared/ui/alert'; +import { HlmButton } from '@shared/ui/button'; +import { HlmComboboxImports } from '@shared/ui/combobox'; +import { HlmFieldImports } from '@shared/ui/field'; +import { HlmInput } from '@shared/ui/input'; +import { HlmSelectImports } from '@shared/ui/select'; +import { HlmSpinner } from '@shared/ui/spinner'; +import type { MaintenancePlanDraft } from './models/maintenance-plan-draft.model'; + +/** + * Class MaintenancePlanForm + * @class MaintenancePlanForm + * + * @description + * Presentational preparation form with explicit calendar cadence and server-paged equipment + * choices. The page owns writes and calendar preview; rejection preserves the draft. + */ +@Component({ + selector: 'app-maintenance-plan-form', + imports: [ + FormField, + ...HlmFieldImports, + ...HlmAlertImports, + ...HlmSelectImports, + ...HlmComboboxImports, + HlmInput, + HlmButton, + HlmSpinner, + ], + templateUrl: './maintenance-plan-form.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class MaintenancePlanForm { + //#region Properties + /** + * Property initialPlan + * @readonly + * + * @description + * Saved plan being configured, otherwise new preparation. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly initialPlan: InputSignal = + input(null); + + /** + * Property equipment + * @readonly + * + * @description + * Current authorized server page. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly equipment: InputSignal = input< + readonly EquipmentOutput[] + >([]); + + /** + * Property equipmentState + * @readonly + * + * @description + * Equipment search feedback. + * + * @access public + * @since unreleased + * + * @type {InputSignal>} + */ + public readonly equipmentState: InputSignal> = + input>(idleCallState()); + + /** + * Property equipmentPage + * @readonly + * + * @description + * Current server option page. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly equipmentPage: InputSignal = input(1); + + /** + * Property equipmentPageCount + * @readonly + * + * @description + * Exact number of server option pages. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly equipmentPageCount: InputSignal = input(1); + + /** + * Property pending + * @readonly + * + * @description + * Accepted write locks the form and dismissal. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly pending: InputSignal = input(false); + + /** + * Property serverError + * @readonly + * + * @description + * Last command rejection retains entered values. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly serverError: InputSignal = input(null); + + /** + * Property submitted + * @readonly + * + * @description + * Validated transport draft; inactive on creation. + * + * @access public + * @since unreleased + * + * @type {OutputEmitterRef} + */ + public readonly submitted: OutputEmitterRef = + output(); + + /** + * Property cancelled + * @readonly + * + * @description + * Explicit draft dismissal. + * + * @access public + * @since unreleased + * + * @type {OutputEmitterRef} + */ + public readonly cancelled: OutputEmitterRef = output(); + + /** + * Property equipmentSearched + * @readonly + * + * @description + * Search text to send to the server. + * + * @access public + * @since unreleased + * + * @type {OutputEmitterRef} + */ + public readonly equipmentSearched: OutputEmitterRef = output(); + + /** + * Property equipmentPageChanged + * @readonly + * + * @description + * Requested server option page. + * + * @access public + * @since unreleased + * + * @type {OutputEmitterRef} + */ + public readonly equipmentPageChanged: OutputEmitterRef = output(); + + /** + * Property labels + * @readonly + * + * @description + * Retains a selected label when another search page arrives. + * + * @access private + * @since unreleased + * + * @type {WritableSignal>} + */ + private readonly labels: WritableSignal> = signal>( + {}, + ); + + /** + * Property model + * @readonly + * + * @description + * Fresh draft follows explicit editor selection. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal} + */ + protected readonly model: WritableSignal = linkedSignal(() => { + const plan: MaintenancePlanOutput | null = this.initialPlan(); + const interval: RegExpMatchArray | null = plan?.interval.match(/^P(\d+)([DWMY])$/) ?? null; + return { + equipmentId: plan?.equipmentId ?? '', + name: plan?.name ?? '', + operationKind: plan?.operationKind ?? 'control', + every: interval ? Number(interval[1]) : 1, + unit: interval?.[2] ?? '', + anchorDate: plan?.anchorAt?.slice(0, 10) ?? '', + firstDueDate: plan?.nextDueAt?.slice(0, 10) ?? '', + }; + }); + + /** + * Property planForm + * @readonly + * + * @description + * Actual field state and validation rules. + * + * @access protected + * @since unreleased + * + * @type {FieldTree} + */ + protected readonly planForm: FieldTree = form(this.model, (path) => { + disabled(path, { when: () => this.pending() }); + disabled(path.every, { when: () => this.calendarLocked() }); + disabled(path.unit, { when: () => this.calendarLocked() }); + disabled(path.anchorDate, { when: () => this.calendarLocked() }); + disabled(path.firstDueDate, { when: () => this.calendarLocked() }); + required(path.equipmentId, { + message: $localize`:@@maintenance.plans.form.equipmentRequired:Select an equipment.`, + }); + required(path.name, { + message: $localize`:@@maintenance.plans.form.nameRequired:Enter an operation name.`, + }); + maxLength(path.name, 160, { + message: $localize`:@@maintenance.plans.form.nameTooLong:Use at most 160 characters.`, + }); + required(path.unit, { + message: $localize`:@@maintenance.plans.form.unitRequired:Choose a calendar unit.`, + }); + min(path.every, 1, { + message: $localize`:@@maintenance.plans.form.everyRequired:Enter a positive interval.`, + }); + validate(path.every, ({ value }) => + Number.isInteger(value()) + ? null + : { + kind: 'integer', + message: $localize`:@@maintenance.plans.form.integerRequired:Enter a whole number.`, + }, + ); + required(path.anchorDate, { + message: $localize`:@@maintenance.plans.form.anchorRequired:Choose an anchor date.`, + }); + }); + + /** + * Property calendarLocked + * @readonly + * + * @description + * An open occurrence retains its cadence and original deadline while allowing renaming. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly calendarLocked: Signal = computed( + () => this.initialPlan()?.openOccurrence != null, + ); + + /** + * Property units + * @readonly + * + * @description + * Calendar units supported by the server. + * + * @access protected + * @since unreleased + * + * @type {readonly { value: string; label: string }[]} + */ + protected readonly units: readonly { value: string; label: string }[] = [ + { value: 'D', label: $localize`:@@maintenance.plans.form.days:Days` }, + { value: 'W', label: $localize`:@@maintenance.plans.form.weeks:Weeks` }, + { value: 'M', label: $localize`:@@maintenance.plans.form.months:Months` }, + { value: 'Y', label: $localize`:@@maintenance.plans.form.years:Years` }, + ]; + + /** + * Property kinds + * @readonly + * + * @description + * Separate control and maintenance choices. + * + * @access protected + * @since unreleased + * + * @type {readonly { value: string; label: string }[]} + */ + protected readonly kinds: readonly { value: string; label: string }[] = [ + { value: 'control', label: $localize`:@@maintenance.plans.control:Control` }, + { value: 'maintenance', label: $localize`:@@maintenance.plans.maintenance:Maintenance` }, + ]; + + /** + * Property serverMessage + * @readonly + * + * @description + * Inline recoverable write error. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly serverMessage: Signal = computed( + () => + this.serverError()?.message ?? + (this.serverError() + ? $localize`:@@maintenance.plans.form.saveError:The plan could not be saved.` + : null), + ); + //#endregion + + //#region Constructor + /** + * Constructor + * @constructor + * + * @description + * Keeps selected equipment labels across option pages. + * + * @access public + * @since unreleased + */ + public constructor() { + effect(() => { + const choices: readonly EquipmentOutput[] = this.equipment(); + this.labels.update((previous) => ({ + ...previous, + ...Object.fromEntries(choices.map((item) => [item.id, buildEquipmentTitle(item)])), + })); + }); + } + //#endregion + + //#region Methods + /** + * Property equipmentLabelOf + * @readonly + * + * @description + * Renders the selected server-authorized equipment label. + * + * @access protected + * @since unreleased + * + * @type {(value: string) => string} + */ + protected readonly equipmentLabelOf: (value: string) => string = (value) => + this.labels()[value] ?? value; + + /** + * Property unitLabelOf + * @readonly + * + * @description + * Names the calendar unit on the select trigger. + * + * @access protected + * @since unreleased + * + * @type {(value: string) => string} + */ + protected readonly unitLabelOf: (value: string) => string = (value) => + this.units.find((option) => option.value === value)?.label ?? ''; + + /** + * Property kindLabelOf + * @readonly + * + * @description + * Names the operation kind on the select trigger. + * + * @access protected + * @since unreleased + * + * @type {(value: string) => string} + */ + protected readonly kindLabelOf: (value: string) => string = (value) => + this.kinds.find((option) => option.value === value)?.label ?? ''; + + /** + * Method submit + * @method submit + * + * @description + * Emits explicit configuration; calendar recurrence and activation belong to the server. + * + * @access protected + * @since unreleased + * + * @param {Event} event - Native form submit. + * + * @returns {void} Emits only when fields are valid and no accepted write is pending. + */ + protected submit(event: Event): void { + event.preventDefault(); + this.planForm().markAsTouched(); + if (this.pending() || this.planForm().invalid()) return; + const draft: MaintenancePlanDraft = this.model(); + if ( + !this.calendarLocked() && + (!/^\d{4}-\d{2}-\d{2}$/.test(draft.anchorDate) || + (draft.firstDueDate && !/^\d{4}-\d{2}-\d{2}$/.test(draft.firstDueDate))) + ) + return; + this.submitted.emit({ + equipmentId: draft.equipmentId, + name: draft.name.trim(), + operationKind: draft.operationKind, + interval: `P${draft.every}${draft.unit}`, + anchorOn: draft.anchorDate, + ...(draft.firstDueDate ? { nextDueOn: draft.firstDueDate } : {}), + }); + } + //#endregion +} diff --git a/src/app/features/organization/features/maintenance-schedules/ui/forms/maintenance-plan-form/models/maintenance-plan-draft.model.ts b/src/app/features/organization/features/maintenance-schedules/ui/forms/maintenance-plan-form/models/maintenance-plan-draft.model.ts new file mode 100644 index 000000000..a958ea3b3 --- /dev/null +++ b/src/app/features/organization/features/maintenance-schedules/ui/forms/maintenance-plan-form/models/maintenance-plan-draft.model.ts @@ -0,0 +1,88 @@ +import type { MaintenanceOperationKind } from '@features/organization/features/maintenance-schedules/models'; + +/** + * Interface MaintenancePlanDraft + * @interface MaintenancePlanDraft + * + * @description + * Explicit calendar configuration; no equipment type implies a regulatory frequency. + */ +export interface MaintenancePlanDraft { + /** + * Property equipmentId + * + * @description + * Selected equipment identity. + * + * @since unreleased + * + * @type {string} + */ + equipmentId: string; + /** + * Property name + * + * @description + * Operation label. + * + * @since unreleased + * + * @type {string} + */ + name: string; + /** + * Property operationKind + * + * @description + * Control or independent maintenance. + * + * @since unreleased + * + * @type {MaintenanceOperationKind} + */ + operationKind: MaintenanceOperationKind; + /** + * Property every + * + * @description + * Positive calendar interval multiplier. + * + * @since unreleased + * + * @type {number} + */ + every: number; + /** + * Property unit + * + * @description + * Explicit ISO calendar unit, unset on a new draft. + * + * @since unreleased + * + * @type {string} + */ + unit: string; + /** + * Property anchorDate + * + * @description + * Calendar anchor date. + * + * @since unreleased + * + * @type {string} + */ + anchorDate: string; + /** + * Property firstDueDate + * + * @description + * Optional explicit first deadline. + * + * @since unreleased + * + * @type {string} + */ + firstDueDate: string; +} diff --git a/src/app/features/organization/features/maintenance-schedules/ui/forms/maintenance-plan-form/testing/maintenance-plan-form.component.spec.ts b/src/app/features/organization/features/maintenance-schedules/ui/forms/maintenance-plan-form/testing/maintenance-plan-form.component.spec.ts new file mode 100644 index 000000000..4a0f35d53 --- /dev/null +++ b/src/app/features/organization/features/maintenance-schedules/ui/forms/maintenance-plan-form/testing/maintenance-plan-form.component.spec.ts @@ -0,0 +1,131 @@ +import { TestBed } from '@angular/core/testing'; +import type { MaintenancePlanOutput } from '@features/organization/features/maintenance-schedules/models'; +import { MaintenancePlanForm } from '../maintenance-plan-form.component'; + +describe('MaintenancePlanForm', () => { + beforeEach(() => TestBed.configureTestingModule({ imports: [MaintenancePlanForm] })); + + it('requires an explicit calendar unit and equipment before preparation', () => { + const fixture = TestBed.createComponent(MaintenancePlanForm); + fixture.detectChanges(); + const emitted = vi.spyOn(fixture.componentInstance.submitted, 'emit'); + fixture.componentInstance['submit'](new Event('submit')); + expect(emitted).not.toHaveBeenCalled(); + expect(fixture.componentInstance['planForm'].unit().invalid()).toBe(true); + expect(fixture.componentInstance['planForm'].equipmentId().invalid()).toBe(true); + }); + + it.each(['D', 'W', 'M', 'Y'])('emits calendar unit %s without computing future dates', (unit) => { + const fixture = TestBed.createComponent(MaintenancePlanForm); + fixture.detectChanges(); + fixture.componentInstance['model'].set({ + equipmentId: 'equipment-1', + name: 'Service', + operationKind: 'maintenance', + every: 2, + unit, + anchorDate: '2026-01-31', + firstDueDate: '', + }); + const emitted = vi.spyOn(fixture.componentInstance.submitted, 'emit'); + fixture.componentInstance['submit'](new Event('submit')); + expect(emitted).toHaveBeenCalledWith({ + equipmentId: 'equipment-1', + name: 'Service', + operationKind: 'maintenance', + interval: `P2${unit}`, + anchorOn: '2026-01-31', + }); + }); + + it('keeps the entered draft after server rejection and blocks pending resubmission', () => { + const fixture = TestBed.createComponent(MaintenancePlanForm); + fixture.detectChanges(); + fixture.componentInstance['model'].set({ + equipmentId: 'equipment-1', + name: 'Annual check', + operationKind: 'control', + every: 1, + unit: 'Y', + anchorDate: '2026-01-01', + firstDueDate: '2026-04-01', + }); + fixture.componentRef.setInput('serverError', { message: 'Rejected', code: 422 }); + fixture.detectChanges(); + expect(fixture.componentInstance['model']().firstDueDate).toBe('2026-04-01'); + expect(fixture.nativeElement.textContent).toContain('Rejected'); + fixture.componentRef.setInput('pending', true); + fixture.detectChanges(); + const emitted = vi.spyOn(fixture.componentInstance.submitted, 'emit'); + fixture.componentInstance['submit'](new Event('submit')); + expect(emitted).not.toHaveBeenCalled(); + }); + + it('rejects fractional cadence without silently rounding it', () => { + const fixture = TestBed.createComponent(MaintenancePlanForm); + fixture.detectChanges(); + fixture.componentInstance['model'].set({ + equipmentId: 'equipment-1', + name: 'Service', + operationKind: 'maintenance', + every: 1.5, + unit: 'M', + anchorDate: '2026-01-31', + firstDueDate: '', + }); + const emitted = vi.spyOn(fixture.componentInstance.submitted, 'emit'); + fixture.componentInstance['submit'](new Event('submit')); + expect(emitted).not.toHaveBeenCalled(); + }); + + it.each(['2027-01-31T00:00:00+01:00', null])( + 'locks an open calendar and allows renaming with historical anchor %s', + (anchorAt) => { + const plan: MaintenancePlanOutput = { + '@id': '/api/plans/monthly-1', + '@type': 'MaintenancePlan', + id: 'monthly-1', + organizationId: 'org-1', + equipmentId: 'equipment-1', + equipmentType: 'fire_extinguisher', + name: 'Monthly maintenance', + operationKind: 'maintenance', + interval: 'P1M', + cadenceMode: anchorAt ? 'fixed' : 'legacy', + calendarTimezone: 'Europe/Paris', + anchorAt, + nextDueAt: '2027-02-28T00:00:00+01:00', + active: true, + openOccurrence: { + id: 'occurrence-1', + dueAt: '2027-02-28T00:00:00+01:00', + attempt: 1, + status: 'open', + }, + }; + const fixture = TestBed.createComponent(MaintenancePlanForm); + fixture.componentRef.setInput('initialPlan', plan); + fixture.detectChanges(); + const form = fixture.componentInstance['planForm']; + expect(form.every().disabled()).toBe(true); + expect(form.unit().disabled()).toBe(true); + expect(form.anchorDate().disabled()).toBe(true); + expect(form.firstDueDate().disabled()).toBe(true); + expect(form.name().disabled()).toBe(false); + const host: HTMLElement = fixture.nativeElement as HTMLElement; + expect(host.querySelector('#maintenance-plan-anchor')?.disabled).toBe(true); + expect( + host.querySelector('[data-testid="maintenance-plan-calendar-locked"]')?.textContent, + ).toContain('You can still change the operation name.'); + fixture.componentInstance['model'].update((draft) => ({ + ...draft, + name: 'Renamed maintenance', + })); + const emitted = vi.spyOn(fixture.componentInstance.submitted, 'emit'); + fixture.componentInstance['submit'](new Event('submit')); + expect(emitted).toHaveBeenCalledWith( + expect.objectContaining({ name: 'Renamed maintenance' }), + ); + }, + ); +}); diff --git a/src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-plans-page/index.ts b/src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-plans-page/index.ts new file mode 100644 index 000000000..9f9ac9453 --- /dev/null +++ b/src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-plans-page/index.ts @@ -0,0 +1 @@ +export { MaintenancePlansPage } from './maintenance-plans-page.component'; diff --git a/src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-plans-page/maintenance-plans-page.component.html b/src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-plans-page/maintenance-plans-page.component.html new file mode 100644 index 000000000..c3af9b330 --- /dev/null +++ b/src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-plans-page/maintenance-plans-page.component.html @@ -0,0 +1,312 @@ +
+
+ Historical schedules + @if (canManage()) { + + } +
+

+ Keep controls and maintenance independent. Preparing work never marks an operation as completed. +

+ @if (store.engineCallState().error; as error) { +
+

Scheduling authority unavailable

+

{{ error.message }}

+ +
+ } + @if (legacyMode()) { +
+

+ Historical scheduling remains active +

+

+ Prepare the historical plans and review their dates before switching. Existing intervals and + recorded history are retained. Only one scheduling engine generates work. +

+ @if (canManage()) { +
+ + +
+ } + @if (store.migrationCallState().error; as error) { + + } +
+ } + @if (formVisible()) { +
+

+ Operation configuration +

+ +
+ } + @if (store.selectedPlan(); as plan) { +
+
+

{{ plan.name }}

+ +
+

+ The server calculates these next dates. Review them before activating this operation. +

+ @if (store.previewCallState().status === 'pending') { + + } + @if (store.previewCallState().error; as error) { +
+

{{ error.message }}

+ +
+ } + @if (store.previewCallState().data; as preview) { +
    + @for (date of preview.dates; track $index) { +
  1. {{ dateValue(date, plan) | appOrgDate: dateMode(plan) : regionalFormatting() }}
  2. + } +
+ @if (preview.dates.length === 0) { +

+ Configure the first deadline to preview this historical operation. +

+ } + @if (canManage()) { +
+ @if (!plan.active) { + + } @else { + + } +
+ } + } + @if (store.updateCallState().error; as error) { + + } +
+ } + @if (store.generationCallState().error; as error) { +
+

+ Intervention could not be prepared +

+

{{ error.message }}

+
+ } + @if (store.generationCallState().data; as work) { + Open intervention #{{ work.number }} + } + + + + + +
+
+ + @if (store.listCallState().error; as error) { +
+
+

+ Operation plans could not be loaded +

+

{{ error.message }}

+
+
+ +
+
+ } + @if (store.listCallState().status === 'pending' && store.planEntities().length === 0) { + + } + @if (store.listCallState().status === 'success' && store.totalPlans() === 0) { +
+
+ +

+ No operation plans match this view +

+

+ Adjust your search or prepare an equipment operation. +

+
+
+ } + + +
+
+
+
+ + +

+ Activate operation plans for this organization? +

+

+ This switches work generation from historical schedules to the prepared operation plans. The + server checks existing campaigns and blocks ambiguous links. Historical intervals and + results remain available. +

+ @if (store.migrationCallState().error; as error) { +
+

{{ error.message }}

+
+ } + +
+
diff --git a/src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-plans-page/maintenance-plans-page.component.ts b/src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-plans-page/maintenance-plans-page.component.ts new file mode 100644 index 000000000..a9cd29c46 --- /dev/null +++ b/src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-plans-page/maintenance-plans-page.component.ts @@ -0,0 +1,721 @@ +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + afterNextRender, + computed, + effect, + inject, + input, + signal, + untracked, + type InputSignal, + type Signal, + type WritableSignal, +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { RouterLink } from '@angular/router'; +import { Events } from '@ngrx/signals/events'; +import { OrganizationPermissionService } from '@features/organization/access'; +import type { + CreateMaintenancePlanInput, + MaintenanceOperationKind, + MaintenancePlanOutput, +} from '@features/organization/features/maintenance-schedules/models'; +import { + MaintenancePlansStore, + maintenancePlansStoreEvents, + type MaintenancePlansStoreType, +} from '@features/organization/features/maintenance-schedules/state'; +import { + maintenancePlanDateMode, + maintenancePlanDateValue, +} from '@features/organization/features/maintenance-schedules/utils'; +import { ORGANIZATION_PERMISSION } from '@features/organization/models'; +import { + REGIONAL_FORMATTING_PORT, + type RegionalFormattingPort, +} from '@features/organization/ports'; +import { CollectionPagination } from '@shared/collection-pagination'; +import { CollectionSearchBox } from '@shared/collection-toolbar'; +import { OrgDatePipe, type RegionalFormatSettings } from '@shared/regional-format'; +import { ResourceIllustration } from '@shared/resource-illustration'; +import { HlmAlertImports } from '@shared/ui/alert'; +import { HlmAlertDialogImports } from '@shared/ui/alert-dialog'; +import { HlmButton } from '@shared/ui/button'; +import { HlmEmptyImports } from '@shared/ui/empty'; +import { HlmSkeleton } from '@shared/ui/skeleton'; +import { HlmSpinner } from '@shared/ui/spinner'; +import { HlmTabsImports } from '@shared/ui/tabs'; +import { MaintenancePlanList } from '../../components/maintenance-plan-list'; +import { MaintenancePlanForm } from '../../forms/maintenance-plan-form'; + +/** + * Class MaintenancePlansPage + * @class MaintenancePlansPage + * + * @description + * Equipment operation library with server previews, explicit activation and single-engine + * migration. Authenticated reads start after hydration; equipment choices load only on editor + * demand. + */ +@Component({ + selector: 'app-maintenance-plans-page', + imports: [ + RouterLink, + OrgDatePipe, + CollectionPagination, + CollectionSearchBox, + ResourceIllustration, + MaintenancePlanList, + MaintenancePlanForm, + HlmButton, + HlmSpinner, + HlmSkeleton, + ...HlmAlertImports, + ...HlmAlertDialogImports, + ...HlmEmptyImports, + ...HlmTabsImports, + ], + templateUrl: './maintenance-plans-page.component.html', + host: { class: 'flex min-h-0 flex-1 flex-col' }, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class MaintenancePlansPage { + //#region Properties + /** + * Property dateMode + * @readonly + * + * @description + * Date-only anchors remain on the selected calendar day in every organization timezone. + * + * @access protected + * @since unreleased + * + * @type {typeof maintenancePlanDateMode} + */ + protected readonly dateMode: typeof maintenancePlanDateMode = maintenancePlanDateMode; + + /** + * Property dateValue + * @readonly + * + * @description + * Supplies the server's calendar day to date-only formatting without converting its offset. + * + * @access protected + * @since unreleased + * + * @type {typeof maintenancePlanDateValue} + */ + protected readonly dateValue: typeof maintenancePlanDateValue = maintenancePlanDateValue; + + /** + * Property organizationId + * @readonly + * + * @description + * Route-bound organization. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly organizationId: InputSignal = input.required(); + + /** + * Property equipmentId + * @readonly + * + * @description + * Optional equipment scope bound from the dossier's navigation query. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly equipmentId: InputSignal = input(); + + /** + * Property operationKind + * @readonly + * + * @description + * Optional operation kind bound from a dossier link, validated before querying. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly operationKind: InputSignal = + input(); + + /** + * Property store + * @readonly + * + * @description + * Page-scoped plans and operation states. + * + * @access protected + * @since unreleased + * + * @type {MaintenancePlansStoreType} + */ + protected readonly store: MaintenancePlansStoreType = inject(MaintenancePlansStore); + + /** + * Property permissions + * @readonly + * + * @description + * Existing API grants gate all commands. + * + * @access private + * @since unreleased + * + * @type {OrganizationPermissionService} + */ + private readonly permissions: OrganizationPermissionService = inject( + OrganizationPermissionService, + ); + + /** + * Property formatting + * @readonly + * + * @description + * Organization date context. + * + * @access private + * @since unreleased + * + * @type {RegionalFormattingPort} + */ + private readonly formatting: RegionalFormattingPort = inject(REGIONAL_FORMATTING_PORT); + + /** + * Property regionalFormatting + * @readonly + * + * @description + * Dates render in the organization timezone. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly regionalFormatting: Signal = + this.formatting.regionalFormatting; + + /** + * Property browserReady + * @readonly + * + * @description + * Prevents secondary authenticated reads during SSR. + * + * @access private + * @since unreleased + * + * @type {WritableSignal} + */ + private readonly browserReady: WritableSignal = signal(false); + + /** + * Property kind + * @readonly + * + * @description + * Current server operation filter. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal} + */ + protected readonly kind: WritableSignal = + signal('control'); + + /** + * Property search + * @readonly + * + * @description + * Applied server search term. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal} + */ + protected readonly search: WritableSignal = signal(''); + + /** + * Property page + * @readonly + * + * @description + * Current server list page. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal} + */ + protected readonly page: WritableSignal = signal(1); + + /** + * Property pageSize + * @readonly + * + * @description + * Current bounded server page size. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal} + */ + protected readonly pageSize: WritableSignal = signal(30); + + /** + * Property formVisible + * @readonly + * + * @description + * Explicit preparation/configuration editor visibility. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal} + */ + protected readonly formVisible: WritableSignal = signal(false); + + /** + * Property editingPlan + * @readonly + * + * @description + * Immutable original equipment and operation when configuring. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal} + */ + protected readonly editingPlan: WritableSignal = + signal(null); + + /** + * Property equipmentSearch + * @readonly + * + * @description + * Last equipment server search. + * + * @access private + * @since unreleased + * + * @type {WritableSignal} + */ + private readonly equipmentSearch: WritableSignal = signal(''); + + /** + * Property equipmentPage + * @readonly + * + * @description + * Current authorized equipment option page. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal} + */ + protected readonly equipmentPage: WritableSignal = signal(1); + + /** + * Property migrationConfirmed + * @readonly + * + * @description + * Explicit consequential authority confirmation. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal} + */ + protected readonly migrationConfirmed: WritableSignal = signal(false); + + /** + * Property canManage + * @readonly + * + * @description + * Existing maintenance management permission. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly canManage: Signal = computed(() => + this.permissions.hasPermission(ORGANIZATION_PERMISSION.MAINTENANCE_MANAGE), + ); + + /** + * Property legacyMode + * @readonly + * + * @description + * Confirmed historical scheduling authority. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly legacyMode: Signal = computed( + () => this.store.engineCallState().data?.mode === 'legacy', + ); + + /** + * Property canGenerate + * @readonly + * + * @description + * Plan engine plus both API command permissions. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly canGenerate: Signal = computed( + () => + this.canManage() && + this.permissions.hasPermission(ORGANIZATION_PERMISSION.INTERVENTIONS_PLAN) && + this.store.engineCallState().status === 'success' && + this.store.engineCallState().data?.mode === 'plans', + ); + + /** + * Property pageCount + * @readonly + * + * @description + * Pages from the authoritative total. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly pageCount: Signal = computed(() => + Math.max(1, Math.ceil(this.store.totalPlans() / this.pageSize())), + ); + + /** + * Property equipmentPageCount + * @readonly + * + * @description + * Authorized selector pages from server total. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly equipmentPageCount: Signal = computed(() => + Math.max(1, Math.ceil(this.store.totalEquipment() / 30)), + ); + //#endregion + + //#region Constructor + /** + * Constructor + * @constructor + * + * @description + * Initializes hydrated scope and confirmed command consequences. + * + * @access public + * @since unreleased + */ + public constructor() { + const events: Events = inject(Events); + const destroyRef: DestroyRef = inject(DestroyRef); + afterNextRender(() => this.browserReady.set(true)); + effect(() => { + const operationKind: MaintenanceOperationKind | undefined = this.operationKind(); + untracked(() => { + this.page.set(1); + this.kind.set(operationKind === 'maintenance' ? 'maintenance' : 'control'); + }); + }); + effect(() => { + const organizationId: string = this.organizationId(); + if (!this.browserReady()) return; + untracked(() => { + this.formVisible.set(false); + this.editingPlan.set(null); + this.migrationConfirmed.set(false); + this.store.setScope(organizationId); + this.store.loadEngine(organizationId); + }); + }); + effect(() => { + const organizationId: string = this.organizationId(); + const operationKind: MaintenanceOperationKind = this.kind(); + const search: string = this.search(); + const page: number = this.page(); + const itemsPerPage: number = this.pageSize(); + const equipmentId: string | undefined = this.equipmentId(); + if (!this.browserReady()) return; + untracked(() => + this.store.load({ + organizationId, + options: { + search, + page, + itemsPerPage, + params: { operationKind, ...(equipmentId ? { equipmentId } : {}) }, + }, + }), + ); + }); + events + .on(maintenancePlansStoreEvents.planPrepared) + .pipe(takeUntilDestroyed(destroyRef)) + .subscribe(({ payload }) => { + if (payload.organizationId !== this.organizationId()) return; + this.formVisible.set(false); + this.store.preview(payload.plan); + this.reload(); + }); + events + .on(maintenancePlansStoreEvents.planChanged) + .pipe(takeUntilDestroyed(destroyRef)) + .subscribe(({ payload }) => { + if (payload.organizationId !== this.organizationId()) return; + if (this.store.updateCallState().status === 'success') this.formVisible.set(false); + if (this.store.engineCallState().data?.mode === 'plans') this.migrationConfirmed.set(false); + this.reload(); + const plan: MaintenancePlanOutput | null = this.store.selectedPlan(); + if (plan) this.store.preview(plan); + }); + } + //#endregion + + //#region Methods + /** + * Method reload + * + * @description + * Retries the current server plan query. + * + * @access protected + * @since unreleased + * + * @returns {void} Keeps narrowing unchanged. + */ + protected reload(): void { + this.store.load({ + organizationId: this.organizationId(), + options: { + search: this.search(), + page: this.page(), + itemsPerPage: this.pageSize(), + params: { + operationKind: this.kind(), + ...(this.equipmentId() ? { equipmentId: this.equipmentId() as string } : {}), + }, + }, + }); + } + + /** + * Method selectKind + * + * @description + * Separates control and maintenance queries. + * + * @access protected + * @since unreleased + * + * @param {string} value - Native tab value. + * + * @returns {void} Resets paging for the selected operation kind. + */ + protected selectKind(value: string): void { + if (value === 'control' || value === 'maintenance') { + this.page.set(1); + this.kind.set(value); + } + } + + /** + * Method openForm + * + * @description + * Opens an explicit new preparation or configuration. + * + * @access protected + * @since unreleased + * + * @param {MaintenancePlanOutput | null} plan - Saved plan to configure, otherwise new. + * + * @returns {void} Loads options only when needed. + */ + protected openForm(plan: MaintenancePlanOutput | null): void { + if (this.store.commandPending() || !this.canManage()) return; + this.editingPlan.set(plan); + this.store.resetCreate(); + this.formVisible.set(true); + if (!plan) this.searchEquipment(''); + } + + /** + * Method submitPlan + * + * @description + * Saves the inactive preparation or editable fields. + * + * @access protected + * @since unreleased + * + * @param {CreateMaintenancePlanInput} values - Validated form values. + * + * @returns {void} Retains the editor until server confirmation. + */ + protected submitPlan(values: CreateMaintenancePlanInput): void { + const plan: MaintenancePlanOutput | null = this.editingPlan(); + if (plan) + this.store.update({ + organizationId: this.organizationId(), + planId: plan.id, + input: { + name: values.name, + ...(!plan.openOccurrence && values.interval !== plan.interval + ? { interval: values.interval } + : {}), + ...(!plan.openOccurrence && values.anchorOn !== plan.anchorAt?.slice(0, 10) + ? { anchorOn: values.anchorOn } + : {}), + ...(!plan.openOccurrence && + values.nextDueOn && + values.nextDueOn !== plan.nextDueAt?.slice(0, 10) + ? { nextDueOn: values.nextDueOn } + : {}), + }, + }); + else this.store.create({ organizationId: this.organizationId(), input: values }); + } + + /** + * Method searchEquipment + * + * @description + * Sends an authorized server search. + * + * @access protected + * @since unreleased + * + * @param {string} search - Search query. + * + * @returns {void} Resets equipment option paging. + */ + protected searchEquipment(search: string): void { + this.equipmentSearch.set(search); + this.loadEquipmentPage(1); + } + + /** + * Method loadEquipmentPage + * + * @description + * Reads one equipment option page. + * + * @access protected + * @since unreleased + * + * @param {number} page - Requested server page. + * + * @returns {void} Keeps the selected form label. + */ + protected loadEquipmentPage(page: number): void { + this.equipmentPage.set(page); + this.store.loadEquipment({ + organizationId: this.organizationId(), + search: this.equipmentSearch(), + page, + }); + } + + /** + * Method setActive + * + * @description + * Changes activation only after date review. + * + * @access protected + * @since unreleased + * + * @param {MaintenancePlanOutput} plan - Reviewed saved plan. + * @param {boolean} active - Explicit desired activation. + * + * @returns {void} Sends the backend-owned activation command. + */ + protected setActive(plan: MaintenancePlanOutput, active: boolean): void { + this.store.update({ + organizationId: this.organizationId(), + planId: plan.id, + input: { active }, + }); + } + + /** + * Method generate + * + * @description + * Sends bounded work creation or an explicit new attempt. + * + * @access protected + * @since unreleased + * + * @param {{ planId: string; retry: boolean }} request - User's generation intent. + * + * @returns {void} Preserves the occurrence's original due date. + */ + protected generate(request: { planId: string; retry: boolean }): void { + this.store.generate({ organizationId: this.organizationId(), ...request }); + } + + /** + * Method closeMigration + * + * @description + * Keeps an accepted authority change busy-locked. + * + * @access protected + * @since unreleased + * + * @param {string} state - Native overlay state. + * + * @returns {void} Dismisses only an idle confirmation. + */ + protected closeMigration(state: string): void { + if (state === 'closed' && this.store.migrationCallState().status !== 'pending') + this.migrationConfirmed.set(false); + } + //#endregion +} diff --git a/src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-plans-page/testing/maintenance-plans-page.component.spec.ts b/src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-plans-page/testing/maintenance-plans-page.component.spec.ts new file mode 100644 index 000000000..cae1d9a0d --- /dev/null +++ b/src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-plans-page/testing/maintenance-plans-page.component.spec.ts @@ -0,0 +1,262 @@ +import { signal, type WritableSignal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import { Events } from '@ngrx/signals/events'; +import { EMPTY } from 'rxjs'; +import { INTERACTION_CAPABILITIES_PORT } from '@core/interaction-capabilities'; +import { idleCallState, successCallState, type CallState } from '@core/request-state'; +import { THEME_PORT } from '@core/theme'; +import { OrganizationPermissionService } from '@features/organization/access'; +import type { EquipmentOutput } from '@features/organization/features/equipments/models'; +import type { + MaintenanceEngineOutput, + MaintenancePlanOutput, + MaintenancePlanPreviewOutput, + MaintenancePlanGenerationOutput, +} from '@features/organization/features/maintenance-schedules/models'; +import { MaintenancePlansStore } from '@features/organization/features/maintenance-schedules/state'; +import { REGIONAL_FORMATTING_PORT } from '@features/organization/ports'; +import { MaintenancePlansPage } from '../maintenance-plans-page.component'; + +describe('MaintenancePlansPage', () => { + let load: ReturnType; + let loadEngine: ReturnType; + let loadEquipment: ReturnType; + let update: ReturnType; + let selectedPlan: WritableSignal; + let previewState: WritableSignal>; + beforeEach(() => { + vi.stubGlobal( + 'ResizeObserver', + class { + public observe(): void {} + public unobserve(): void {} + public disconnect(): void {} + }, + ); + load = vi.fn(); + loadEngine = vi.fn(); + loadEquipment = vi.fn(); + update = vi.fn(); + selectedPlan = signal(null); + previewState = signal>(idleCallState()); + TestBed.configureTestingModule({ + imports: [MaintenancePlansPage], + providers: [ + provideRouter([]), + { + provide: THEME_PORT, + useValue: { theme: signal('light'), resolvedTheme: signal('light'), setTheme: vi.fn() }, + }, + { + provide: INTERACTION_CAPABILITIES_PORT, + useValue: { interactionMode: signal('desktop'), isMobileInteractionMode: signal(false) }, + }, + { + provide: MaintenancePlansStore, + useValue: { + load, + loadEngine, + loadEquipment, + update, + setScope: vi.fn(), + resetCreate: vi.fn(), + commandPending: signal(false), + totalPlans: signal(0), + totalEquipment: signal(0), + planEntities: signal([]), + listCallState: signal(successCallState(null)), + engineCallState: signal>( + successCallState({ mode: 'legacy', preparedCount: 0, conflicts: [] }), + ), + migrationCallState: signal>(idleCallState()), + createCallState: signal>(idleCallState()), + updateCallState: signal>(idleCallState()), + equipmentCallState: signal>(idleCallState()), + selectedPlan, + previewCallState: previewState, + generationCallState: + signal>(idleCallState()), + }, + }, + { provide: OrganizationPermissionService, useValue: { hasPermission: () => true } }, + { + provide: REGIONAL_FORMATTING_PORT, + useValue: { regionalFormatting: signal({ dateFormat: 'yyyy-MM-dd', timezone: 'UTC' }) }, + }, + { provide: Events, useValue: { on: () => EMPTY } }, + ], + }); + }); + afterEach(() => vi.unstubAllGlobals()); + + it('forwards dossier equipment scope and operation kind without loading hidden choices', async () => { + const fixture = TestBed.createComponent(MaintenancePlansPage); + fixture.componentRef.setInput('organizationId', 'org-1'); + fixture.componentRef.setInput('equipmentId', 'equipment-1'); + fixture.componentRef.setInput('operationKind', 'maintenance'); + fixture.detectChanges(); + await fixture.whenStable(); + expect(load).toHaveBeenLastCalledWith({ + organizationId: 'org-1', + options: expect.objectContaining({ + params: { equipmentId: 'equipment-1', operationKind: 'maintenance' }, + }), + }); + expect(loadEngine).toHaveBeenCalledWith('org-1'); + expect(loadEquipment).not.toHaveBeenCalled(); + }); + + it('loads equipment only after explicit new-plan preparation', async () => { + const fixture = TestBed.createComponent(MaintenancePlansPage); + fixture.componentRef.setInput('organizationId', 'org-1'); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.componentInstance['openForm'](null); + expect(loadEquipment).toHaveBeenCalledWith({ organizationId: 'org-1', search: '', page: 1 }); + }); + + it.each([ + null, + { id: 'occurrence-1', dueAt: '2027-02-28T00:00:00+01:00', attempt: 1, status: 'open' as const }, + ])( + 'renames a plan without retransmitting its unchanged calendar with occurrence %s', + (openOccurrence) => { + const plan: MaintenancePlanOutput = { + '@id': '/api/plans/monthly-1', + '@type': 'MaintenancePlan', + id: 'monthly-1', + organizationId: 'org-1', + equipmentId: 'equipment-1', + equipmentType: 'fire_extinguisher', + name: 'Monthly maintenance', + operationKind: 'maintenance', + interval: 'P1M', + cadenceMode: 'fixed', + calendarTimezone: 'Europe/Paris', + anchorAt: '2027-01-31T00:00:00+01:00', + nextDueAt: '2027-02-28T00:00:00+01:00', + active: true, + openOccurrence, + }; + const fixture = TestBed.createComponent(MaintenancePlansPage); + fixture.componentRef.setInput('organizationId', 'org-1'); + fixture.detectChanges(); + fixture.componentInstance['openForm'](plan); + fixture.componentInstance['submitPlan']({ + equipmentId: plan.equipmentId, + operationKind: plan.operationKind, + name: 'Renamed maintenance', + interval: 'P1M', + anchorOn: '2027-01-31', + nextDueOn: '2027-02-28', + }); + expect(update).toHaveBeenCalledWith({ + organizationId: 'org-1', + planId: 'monthly-1', + input: { name: 'Renamed maintenance' }, + }); + }, + ); + + it('sends a changed calendar only when no occurrence is open', () => { + const plan: MaintenancePlanOutput = { + '@id': '/api/plans/monthly-1', + '@type': 'MaintenancePlan', + id: 'monthly-1', + organizationId: 'org-1', + equipmentId: 'equipment-1', + equipmentType: 'fire_extinguisher', + name: 'Monthly maintenance', + operationKind: 'maintenance', + interval: 'P1M', + cadenceMode: 'fixed', + calendarTimezone: 'Europe/Paris', + anchorAt: '2027-01-31T00:00:00+01:00', + nextDueAt: '2027-02-28T00:00:00+01:00', + active: false, + openOccurrence: null, + }; + const fixture = TestBed.createComponent(MaintenancePlansPage); + fixture.componentRef.setInput('organizationId', 'org-1'); + fixture.detectChanges(); + fixture.componentInstance['openForm'](plan); + fixture.componentInstance['submitPlan']({ + equipmentId: plan.equipmentId, + operationKind: plan.operationKind, + name: plan.name, + interval: 'P2M', + anchorOn: '2027-01-31', + nextDueOn: '2027-03-31', + }); + expect(update).toHaveBeenCalledWith({ + organizationId: 'org-1', + planId: 'monthly-1', + input: { name: plan.name, interval: 'P2M', nextDueOn: '2027-03-31' }, + }); + update.mockClear(); + fixture.componentInstance['openForm']({ + ...plan, + openOccurrence: { + id: 'occurrence-1', + dueAt: plan.nextDueAt as string, + attempt: 1, + status: 'open', + }, + }); + fixture.componentInstance['submitPlan']({ + equipmentId: plan.equipmentId, + operationKind: plan.operationKind, + name: 'Renamed maintenance', + interval: 'P2M', + anchorOn: '2027-02-01', + nextDueOn: '2027-03-31', + }); + expect(update).toHaveBeenCalledWith({ + organizationId: 'org-1', + planId: 'monthly-1', + input: { name: 'Renamed maintenance' }, + }); + }); + + it('preserves the preview calendar dates across timezone offsets and daylight-saving changes', () => { + selectedPlan.set({ + '@id': '/api/plans/monthly-1', + '@type': 'MaintenancePlan', + id: 'monthly-1', + organizationId: 'org-1', + equipmentId: 'equipment-1', + equipmentType: 'fire_extinguisher', + name: 'Monthly maintenance', + operationKind: 'maintenance', + interval: 'P1M', + cadenceMode: 'fixed', + calendarTimezone: 'Europe/Paris', + anchorAt: '2027-01-31T00:00:00+01:00', + nextDueAt: '2027-02-28T00:00:00+01:00', + active: false, + openOccurrence: null, + }); + previewState.set( + successCallState({ + dates: [ + '2027-02-28T00:00:00+01:00', + '2027-03-31T00:00:00+02:00', + '2027-04-30T00:00:00+02:00', + ], + }), + ); + const fixture = TestBed.createComponent(MaintenancePlansPage); + fixture.componentRef.setInput('organizationId', 'org-1'); + fixture.detectChanges(); + const preview: HTMLElement | null = (fixture.nativeElement as HTMLElement).querySelector( + '[data-testid="maintenance-plan-preview"]', + ); + expect(preview?.textContent).toContain('2027-02-28'); + expect(preview?.textContent).toContain('2027-03-31'); + expect(preview?.textContent).toContain('2027-04-30'); + expect(preview?.textContent).not.toContain('2027-02-27'); + expect(preview?.textContent).not.toContain('2027-03-30'); + expect(preview?.textContent).not.toContain('2027-04-29'); + }); +}); diff --git a/src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-schedules-page/maintenance-schedules-page.component.html b/src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-schedules-page/maintenance-schedules-page.component.html index f9d268275..2a15e92b4 100644 --- a/src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-schedules-page/maintenance-schedules-page.component.html +++ b/src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-schedules-page/maintenance-schedules-page.component.html @@ -1,4 +1,28 @@
+ + @if (equipmentTypeCatalog.loadCallState().error; as error) { +
+

Equipment types could not be loaded

+

{{ error.message }}

+ +
+ } } @else { } */ - protected readonly equipmentTypeOptions: typeof EQUIPMENT_TYPE_OPTIONS = EQUIPMENT_TYPE_OPTIONS; + protected readonly equipmentTypeOptions: Signal = computed(() => + this.equipmentTypeCatalog.options(), + ); + + /** + * Property equipmentTypeCatalog + * @readonly + * + * @description + * Page-scoped authorized catalog, retaining historical and custom equipment labels. + * + * @access protected + * @since unreleased + * + * @type {EquipmentTypeCatalogStoreType} + */ + protected readonly equipmentTypeCatalog: EquipmentTypeCatalogStoreType = + inject(EquipmentTypeCatalogStore); + + /** + * Property catalogReady + * @readonly + * + * @description + * Delays secondary equipment catalog reads until browser hydration. + * + * @access private + * @since unreleased + * + * @type {WritableSignal} + */ + private readonly catalogReady: WritableSignal = signal(false); /** * Property facilityOptions @@ -661,7 +700,11 @@ export class MaintenanceSchedulesPage { * @type {Signal} */ protected readonly items: Signal = computed(() => - this.store.schedules(), + this.store + .schedules() + .filter( + (schedule) => schedule.organization === `/api/organizations/${this.organizationId()}`, + ), ); /** @@ -988,6 +1031,14 @@ export class MaintenanceSchedulesPage { */ public constructor() { registerPageActions(this.pageActions, this.pageActionsService, inject(DestroyRef)); + afterNextRender(() => this.catalogReady.set(true)); + effect(() => { + const organizationId: string = this.organizationId(); + const ready: boolean = this.catalogReady(); + untracked(() => + ready ? this.equipmentTypeCatalog.load(organizationId) : this.equipmentTypeCatalog.clear(), + ); + }); toObservable(this.draftSearch) .pipe(debounceTime(SEARCH_DEBOUNCE_MS), distinctUntilChanged(), takeUntilDestroyed()) @@ -1000,8 +1051,16 @@ export class MaintenanceSchedulesPage { }); effect((): void => { - this.organizationId(); - untracked(() => this.facilityOptionsStore.clear()); + const organizationId = this.organizationId(); + untracked(() => { + this.store.setOrganization(`/api/organizations/${organizationId}`); + this.facilityOptionsStore.clear(); + this.overrideTarget.set(null); + this.overrideDialogVisible.set(false); + this.campaignDialogVisible.set(false); + this.openFilterKey.set(null); + this.clearFilters(); + }); }); effect((): void => { @@ -1029,7 +1088,12 @@ export class MaintenanceSchedulesPage { const state = this.store.overrideCallState(); untracked((): void => { - if (isCallSuccess(state) && this.overrideDialogVisible()) { + if ( + isCallSuccess(state) && + state.data.organization === `/api/organizations/${this.organizationId()}` && + state.data.id === this.overrideTarget()?.id && + this.overrideDialogVisible() + ) { this.overrideDialogVisible.set(false); this.overrideTarget.set(null); this.store.resetOverrideOperation(); @@ -1039,14 +1103,15 @@ export class MaintenanceSchedulesPage { effect((): void => { const result = this.store.campaignResult(); + const resultOrganization = this.store.campaignResultOrganization(); untracked((): void => { - if (result) { + if (result && resultOrganization === `/api/organizations/${this.organizationId()}`) { this.campaignDialogVisible.set(false); this.store.resetCampaignOperation(); void this.router.navigate([ '/organizations', - this.organizationId(), + iriId(resultOrganization), 'interventions', result.interventionId, ]); @@ -1382,6 +1447,11 @@ export class MaintenanceSchedulesPage { * @returns {void} */ protected openOverrideDialog(schedule: MaintenanceScheduleOutput): void { + if ( + !this.canManage() || + schedule.organization !== `/api/organizations/${this.organizationId()}` + ) + return; this.overrideTarget.set(schedule); this.overrideDialogVisible.set(true); } @@ -1421,9 +1491,18 @@ export class MaintenanceSchedulesPage { protected submitOverride(value: string | null): void { const target: MaintenanceScheduleOutput | null = this.overrideTarget(); - if (!target) return; + if ( + !target || + !this.canManage() || + target.organization !== `/api/organizations/${this.organizationId()}` + ) + return; - this.store.setIntervalOverride({ scheduleId: target.id, intervalOverride: value }); + this.store.setIntervalOverride({ + organization: target.organization, + scheduleId: target.id, + intervalOverride: value, + }); } /** @@ -1477,6 +1556,7 @@ export class MaintenanceSchedulesPage { * @returns {void} */ protected submitCampaign(scope: Omit): void { + if (!this.campaignDialogVisible() || !this.canPlanCampaign()) return; this.store.generateCampaign({ organization: `/api/organizations/${this.organizationId()}`, ...scope, diff --git a/src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-schedules-page/testing/maintenance-schedules-page.component.spec.ts b/src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-schedules-page/testing/maintenance-schedules-page.component.spec.ts index 0849434be..fc3883abb 100644 --- a/src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-schedules-page/testing/maintenance-schedules-page.component.spec.ts +++ b/src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-schedules-page/testing/maintenance-schedules-page.component.spec.ts @@ -19,6 +19,7 @@ import { idleCallState, successCallState, type CallState } from '@core/request-s import { THEME_PORT, type ThemePort } from '@core/theme'; import { AUTH_SESSION_PORT } from '@features/auth/ports'; import { OrganizationPermissionService } from '@features/organization/access'; +import { EquipmentTypeService } from '@features/organization/features/equipments/data-access'; import { FacilityService } from '@features/organization/features/facilities/data-access'; import { MaintenanceScheduleService } from '@features/organization/features/maintenance-schedules/data-access'; import type { @@ -61,12 +62,14 @@ const renderPageActions = (): HTMLElement => { describe('MaintenanceSchedulesPage', () => { let fixture: ComponentFixture; let load: ReturnType; + let setOrganization: ReturnType; let setIntervalOverride: ReturnType; let generateCampaign: ReturnType; let resetOverrideOperation: ReturnType; let resetCampaignOperation: ReturnType; let overrideCallState: WritableSignal>; let campaignResult: WritableSignal; + let campaignResultOrganization: WritableSignal; let hasPermission: ReturnType; let navigate: ReturnType; let totalSchedules: WritableSignal; @@ -89,14 +92,23 @@ describe('MaintenanceSchedulesPage', () => { updatedAt: '2026-01-01T00:00:00+00:00', }; + const otherSchedule: MaintenanceScheduleOutput = { + ...schedule, + '@id': '/api/maintenance/schedules/schedule-2', + id: 'schedule-2', + organization: '/api/organizations/org-2', + }; + beforeEach(() => { load = vi.fn(); + setOrganization = vi.fn(); setIntervalOverride = vi.fn(); generateCampaign = vi.fn(); resetOverrideOperation = vi.fn(); resetCampaignOperation = vi.fn(); overrideCallState = signal>(idleCallState()); campaignResult = signal(null); + campaignResultOrganization = signal(null); hasPermission = vi.fn().mockReturnValue(true); totalSchedules = signal(1); listCallState = signal(successCallState(null)); @@ -137,6 +149,7 @@ describe('MaintenanceSchedulesPage', () => { provide: MaintenanceSchedulesStore, useValue: { load, + setOrganization, setIntervalOverride, generateCampaign, resetOverrideOperation, @@ -151,11 +164,39 @@ describe('MaintenanceSchedulesPage', () => { isGeneratingCampaign: signal(false), campaignError: signal(null), campaignResult, + campaignResultOrganization, overrideCallState, }, }, { provide: OrganizationPermissionService, useValue: { hasPermission } }, { provide: MaintenanceScheduleService, useValue: { exportCsv } }, + { + provide: EquipmentTypeService, + useValue: { + listAll: vi.fn().mockReturnValue( + of([ + { + '@id': '/api/types/fire_extinguisher', + '@type': 'EquipmentType', + value: 'fire_extinguisher', + label: 'Fire extinguisher', + family: 'fire', + archived: false, + revision: 1, + }, + { + '@id': '/api/types/smoke_detector', + '@type': 'EquipmentType', + value: 'smoke_detector', + label: 'Smoke detector', + family: 'fire', + archived: false, + revision: 1, + }, + ]), + ), + }, + }, { provide: FeedbackService, useValue: { warn: feedbackWarn, error: feedbackError } }, { provide: FacilityService, @@ -173,6 +214,7 @@ describe('MaintenanceSchedulesPage', () => { expect(load).toHaveBeenCalledTimes(1); expect(load.mock.calls[0][0]).toMatchObject({ organization: '/api/organizations/org-1' }); + expect(setOrganization).toHaveBeenCalledWith('/api/organizations/org-1'); }); it('should render search and forward a settled term from the first page', async () => { @@ -206,6 +248,7 @@ describe('MaintenanceSchedulesPage', () => { ).submitOverride('P6M'); expect(setIntervalOverride).toHaveBeenCalledWith({ + organization: '/api/organizations/org-1', scheduleId: 'schedule-1', intervalOverride: 'P6M', }); @@ -235,6 +278,7 @@ describe('MaintenanceSchedulesPage', () => { it('should navigate to the created intervention once a campaign result lands', async () => { fixture = await createPage(); + campaignResultOrganization.set('/api/organizations/org-1'); campaignResult.set({ '@id': '', '@type': 'MaintenanceCampaignResult', @@ -253,6 +297,147 @@ describe('MaintenanceSchedulesPage', () => { expect(resetCampaignOperation).toHaveBeenCalled(); }); + it('clears organization-specific filters and dialogs and hides stale rows on a workspace change', async () => { + fixture = await createPage(); + fixture.componentInstance['openOverrideDialog'](schedule); + fixture.componentInstance['filters'].set({ + dueStatus: 'overdue', + facility: '/api/organizations/org-1/facilities/site-1', + equipmentType: 'fire_extinguisher', + dueBefore: new Date('2026-12-31T00:00:00Z'), + }); + fixture.componentInstance['draftSearch'].set('old organization'); + fixture.componentInstance['searchTerm'].set('old organization'); + fixture.componentInstance['page'].set(3); + fixture.componentInstance['openFilterKey'].set('facility'); + await fixture.whenStable(); + load.mockClear(); + + fixture.componentRef.setInput('organizationId', 'org-2'); + await fixture.whenStable(); + + expect(setOrganization).toHaveBeenLastCalledWith('/api/organizations/org-2'); + expect(fixture.componentInstance['filters']()).toEqual({ + dueStatus: null, + facility: null, + equipmentType: null, + dueBefore: null, + }); + expect(fixture.componentInstance['draftSearch']()).toBe(''); + expect(fixture.componentInstance['searchTerm']()).toBe(''); + expect(fixture.componentInstance['page']()).toBe(1); + expect(fixture.componentInstance['openFilterKey']()).toBeNull(); + expect(fixture.componentInstance['overrideDialogVisible']()).toBe(false); + expect(fixture.componentInstance['overrideTarget']()).toBeNull(); + expect(fixture.componentInstance['items']()).toEqual([]); + expect(load.mock.calls.at(-1)?.[0]).toEqual({ + organization: '/api/organizations/org-2', + facility: undefined, + equipmentType: undefined, + dueStatus: undefined, + dueBefore: undefined, + search: undefined, + page: 1, + itemsPerPage: 30, + }); + + const root: HTMLElement = fixture.nativeElement as HTMLElement; + expect(root.querySelector('[data-testid="maintenance-schedule-table-row"]')).toBeNull(); + expect(root.querySelector('[data-testid="maintenance-schedule-table-override"]')).toBeNull(); + + fixture.componentInstance['openOverrideDialog'](schedule); + fixture.componentInstance['submitOverride']('P3M'); + + expect(fixture.componentInstance['overrideDialogVisible']()).toBe(false); + expect(setIntervalOverride).not.toHaveBeenCalled(); + }); + + it('keeps a reopened organization B override dialog open when organization A succeeds late', async () => { + fixture = await createPage(); + fixture.componentInstance['openOverrideDialog'](schedule); + fixture.componentInstance['submitOverride']('P6M'); + fixture.componentRef.setInput('organizationId', 'org-2'); + await fixture.whenStable(); + schedules.set([schedule, otherSchedule]); + fixture.componentInstance['openOverrideDialog'](otherSchedule); + await fixture.whenStable(); + resetOverrideOperation.mockClear(); + + overrideCallState.set(successCallState({ ...schedule, intervalOverride: 'P6M' })); + await fixture.whenStable(); + + expect(fixture.componentInstance['items']()).toEqual([otherSchedule]); + expect(fixture.componentInstance['overrideDialogVisible']()).toBe(true); + expect(fixture.componentInstance['overrideTarget']()).toEqual(otherSchedule); + expect(resetOverrideOperation).not.toHaveBeenCalled(); + }); + + it('keeps the override dialog open when a different target in the same organization succeeds', async () => { + fixture = await createPage(); + fixture.componentInstance['openOverrideDialog'](schedule); + await fixture.whenStable(); + + overrideCallState.set(successCallState({ ...schedule, id: 'another-schedule' })); + await fixture.whenStable(); + + expect(fixture.componentInstance['overrideDialogVisible']()).toBe(true); + expect(fixture.componentInstance['overrideTarget']()).toEqual(schedule); + expect(resetOverrideOperation).not.toHaveBeenCalled(); + }); + + it('keeps a reopened organization B campaign dialog open when organization A succeeds late', async () => { + fixture = await createPage(); + fixture.componentInstance['openCampaignDialog'](); + fixture.componentInstance['submitCampaign']({ + name: 'Organization A campaign', + dueBefore: '2026-12-31T00:00:00.000Z', + }); + fixture.componentRef.setInput('organizationId', 'org-2'); + await fixture.whenStable(); + expect(fixture.componentInstance['campaignDialogVisible']()).toBe(false); + fixture.componentInstance['openCampaignDialog'](); + await fixture.whenStable(); + resetCampaignOperation.mockClear(); + + campaignResultOrganization.set('/api/organizations/org-1'); + campaignResult.set({ + '@id': '', + '@type': 'MaintenanceCampaignResult', + interventionId: 'intervention-from-org-1', + number: 42, + workItemsCount: 7, + }); + await fixture.whenStable(); + + expect(fixture.componentInstance['campaignDialogVisible']()).toBe(true); + expect(navigate).not.toHaveBeenCalled(); + expect(resetCampaignOperation).not.toHaveBeenCalled(); + }); + + it('navigates a confirmed campaign under its current organization', async () => { + fixture = await createPage(); + fixture.componentRef.setInput('organizationId', 'org-2'); + await fixture.whenStable(); + fixture.componentInstance['openCampaignDialog'](); + campaignResultOrganization.set('/api/organizations/org-2'); + campaignResult.set({ + '@id': '', + '@type': 'MaintenanceCampaignResult', + interventionId: 'intervention-from-org-2', + number: 43, + workItemsCount: 2, + }); + await fixture.whenStable(); + + expect(fixture.componentInstance['campaignDialogVisible']()).toBe(false); + expect(navigate).toHaveBeenCalledWith([ + '/organizations', + 'org-2', + 'interventions', + 'intervention-from-org-2', + ]); + }); + it('should narrow the list to the picked due-status filter', async () => { fixture = await createPage(); load.mockClear(); diff --git a/src/app/features/organization/features/maintenance-schedules/ui/tables/maintenance-schedule-table/maintenance-schedule-table.component.ts b/src/app/features/organization/features/maintenance-schedules/ui/tables/maintenance-schedule-table/maintenance-schedule-table.component.ts index f2f0a31af..15cb80adb 100644 --- a/src/app/features/organization/features/maintenance-schedules/ui/tables/maintenance-schedule-table/maintenance-schedule-table.component.ts +++ b/src/app/features/organization/features/maintenance-schedules/ui/tables/maintenance-schedule-table/maintenance-schedule-table.component.ts @@ -14,7 +14,7 @@ import { import { RouterLink } from '@angular/router'; import { NgIcon, provideIcons } from '@ng-icons/core'; import { lucidePencil } from '@ng-icons/lucide'; -import { EQUIPMENT_TYPE_OPTIONS } from '@features/organization/features/equipments'; +import type { EquipmentTypeOption } from '@features/organization/features/equipments'; import type { MaintenanceScheduleOutput } from '@features/organization/features/maintenance-schedules/models'; import { MAINTENANCE_OVERRIDE_DURATION_OPTIONS } from '@features/organization/features/maintenance-schedules/options'; import { iriId } from '@features/organization/features/maintenance-schedules/utils'; @@ -42,19 +42,16 @@ import { MaintenanceDueStatusTag } from '../../components/maintenance-due-status * "Never inspected" for a tracked-but-unreviewed schedule, per the backend * contract), the authoritative due-status badge, and the current interval * override with an Edit action. - * * Presentational (`ARCHITECTURE.md` §10.3) — it injects no store and calls * no service. The page decides what to load, filter and paginate, and * whether the operator may manage overrides ({@link canManage}); this * component only renders the page it is handed and emits * {@link overrideRequested} for the page to open the override dialog. - * * The facility cell and the equipment link's accessible name both resolve * through {@link facilityLabelOf}, the page's own facility catalog, so two * rows tracking the same equipment type at different facilities read as * distinguishable rather than an identical "Fire extinguisher" / * "View facility" pair pointing at different records. - * * Built on the shared `CollectionSurface`, which owns the bordered scroll * shell, the first-load skeleton and the table/card switch. Below the * surface's container breakpoint a schedule reads as a card: the equipment @@ -90,12 +87,32 @@ import { MaintenanceDueStatusTag } from '../../components/maintenance-due-status }) export class MaintenanceScheduleTable { //#region Inputs + /** + * Property equipmentTypeOptions + * @readonly + * + * @description + * Authorized catalog labels for current and historical equipment type codes. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly equipmentTypeOptions: InputSignal = input< + readonly EquipmentTypeOption[] + >([]); + /** * Property items * @readonly - * @description The rows to render — already filtered, ordered and paged by the page. + * + * @description + * The rows to render — already filtered, ordered and paged by the page. + * * @access public * @since 1.0.0 + * * @type {InputSignal} */ public readonly items: InputSignal = @@ -104,9 +121,13 @@ export class MaintenanceScheduleTable { /** * Property loading * @readonly - * @description Whether to draw placeholder rows instead of the data. + * + * @description + * Whether to draw placeholder rows instead of the data. + * * @access public * @since 1.0.0 + * * @type {InputSignal} */ public readonly loading: InputSignal = input(false); @@ -114,9 +135,14 @@ export class MaintenanceScheduleTable { /** * Property canManage * @readonly - * @description Whether the active member may open the interval-override dialog (`organization.maintenance.manage`). + * + * @description + * Whether the active member may open the interval-override dialog + * (`organization.maintenance.manage`). + * * @access public * @since 1.0.0 + * * @type {InputSignal} */ public readonly canManage: InputSignal = input(false); @@ -124,9 +150,13 @@ export class MaintenanceScheduleTable { /** * Property equipmentRouteBase * @readonly - * @description Path segments the equipment link appends the equipment id to. + * + * @description + * Path segments the equipment link appends the equipment id to. + * * @access public * @since 1.0.0 + * * @type {InputSignal} */ public readonly equipmentRouteBase: InputSignal = @@ -135,9 +165,13 @@ export class MaintenanceScheduleTable { /** * Property facilityRouteBase * @readonly - * @description Path segments the facility link appends the facility id to. + * + * @description + * Path segments the facility link appends the facility id to. + * * @access public * @since 1.0.0 + * * @type {InputSignal} */ public readonly facilityRouteBase: InputSignal = @@ -157,6 +191,7 @@ export class MaintenanceScheduleTable { * * @access public * @since 1.1.0 + * * @type {InputSignal<(facilityId: string) => string | null>} */ public readonly facilityLabelOf: InputSignal<(facilityId: string) => string | null> = input< @@ -166,9 +201,14 @@ export class MaintenanceScheduleTable { /** * Property regionalFormatting * @readonly - * @description The active organization's date pattern and timezone, bound by the page. The default keeps the component renderable with no context wired. + * + * @description + * The active organization's date pattern and timezone, bound by the page. The default keeps the + * component renderable with no context wired. + * * @access public * @since 1.0.0 + * * @type {InputSignal} */ public readonly regionalFormatting: InputSignal = @@ -179,9 +219,13 @@ export class MaintenanceScheduleTable { /** * Property overrideRequested * @readonly - * @description A row's Edit action was activated; carries that row's schedule. + * + * @description + * A row's Edit action was activated; carries that row's schedule. + * * @access public * @since 1.0.0 + * * @type {OutputEmitterRef} */ public readonly overrideRequested: OutputEmitterRef = @@ -189,10 +233,33 @@ export class MaintenanceScheduleTable { //#endregion //#region Properties - /** The application's active locale, for the relative next-due label. */ + /** + * Property locale + * @readonly + * + * @description + * The application's active locale, for the relative next-due label. + * + * @access private + * @since unreleased + * + * @type {string} + */ private readonly locale: string = inject(LOCALE_ID); - /** Pure, dependency-free date formatter for {@link evaluationTooltipOf} and the next-due cell — no DI needed for a single-instance internal use. */ + /** + * Property orgDatePipe + * @readonly + * + * @description + * Pure, dependency-free date formatter for {@link evaluationTooltipOf} and the next-due cell — no + * DI needed for a single-instance internal use. + * + * @access private + * @since unreleased + * + * @type {OrgDatePipe} + */ private readonly orgDatePipe: OrgDatePipe = new OrgDatePipe(); /** @@ -224,10 +291,15 @@ export class MaintenanceScheduleTable { //#region Methods /** * Method equipmentIdOf - * @description The bare id extracted from a schedule's equipment IRI, for the row link. + * + * @description + * The bare id extracted from a schedule's equipment IRI, for the row link. + * * @access protected * @since 1.0.0 + * * @param {MaintenanceScheduleOutput} item - The rendered schedule. + * * @returns {string} The equipment id. */ protected equipmentIdOf(item: MaintenanceScheduleOutput): string { @@ -236,10 +308,15 @@ export class MaintenanceScheduleTable { /** * Method facilityIdOf - * @description The bare id extracted from a schedule's facility IRI, or `null` when unassigned. + * + * @description + * The bare id extracted from a schedule's facility IRI, or `null` when unassigned. + * * @access protected * @since 1.0.0 + * * @param {MaintenanceScheduleOutput} item - The rendered schedule. + * * @returns {string | null} The facility id, or `null`. */ protected facilityIdOf(item: MaintenanceScheduleOutput): string | null { @@ -277,25 +354,36 @@ export class MaintenanceScheduleTable { /** * Method equipmentTypeLabelOf - * @description The schedule's equipment type, humanized through the shared type catalog. + * + * @description + * The schedule's equipment type, humanized through the shared type catalog. + * * @access protected * @since 1.0.0 + * * @param {string} equipmentType - The raw type value. + * * @returns {string} The localized label, or the raw value humanized if unknown. */ protected equipmentTypeLabelOf(equipmentType: string): string { return ( - EQUIPMENT_TYPE_OPTIONS.find((option) => option.value === equipmentType)?.label ?? + this.equipmentTypeOptions().find((option) => option.value === equipmentType)?.label ?? equipmentType.replaceAll('_', ' ') ); } /** * Method overrideLabelOf - * @description The schedule's interval override, humanized through the duration catalog, or `null` when it follows the organization default. + * + * @description + * The schedule's interval override, humanized through the duration catalog, or `null` when it + * follows the organization default. + * * @access protected * @since 1.0.0 + * * @param {MaintenanceScheduleOutput} item - The rendered schedule. + * * @returns {string | null} The localized duration label, or `null`. */ protected overrideLabelOf(item: MaintenanceScheduleOutput): string | null { @@ -309,10 +397,16 @@ export class MaintenanceScheduleTable { /** * Method isNeverInspected - * @description Whether a schedule is tracked but has never had an inspection recorded — `overdue` with no `nextDueAt` — the state the contract requires an explicit label for. + * + * @description + * Whether a schedule is tracked but has never had an inspection recorded — `overdue` with no + * `nextDueAt` — the state the contract requires an explicit label for. + * * @access protected * @since 1.0.0 + * * @param {MaintenanceScheduleOutput} item - The rendered schedule. + * * @returns {boolean} `true` for the never-inspected state. */ protected isNeverInspected(item: MaintenanceScheduleOutput): boolean { @@ -321,9 +415,14 @@ export class MaintenanceScheduleTable { /** * Method columnCount - * @description How many cells a row has, so a full-width message can span them. The actions column only exists with {@link canManage}. + * + * @description + * How many cells a row has, so a full-width message can span them. The actions column only exists + * with {@link canManage}. + * * @access protected * @since 1.0.0 + * * @returns {number} The rendered column count. */ protected columnCount(): number { @@ -409,11 +508,18 @@ export class MaintenanceScheduleTable { /** * Method calendarDateIn - * @description The `YYYY-MM-DD` calendar day an instant falls on within the given timezone, falling back to the instant's own written date when the timezone identifier is not one `Intl.DateTimeFormat` accepts (a fixed `+HHMM` offset). + * + * @description + * The `YYYY-MM-DD` calendar day an instant falls on within the given timezone, falling back to + * the instant's own written date when the timezone identifier is not one `Intl.DateTimeFormat` + * accepts (a fixed `+HHMM` offset). + * * @access private * @since 2.1.0 + * * @param {string} instantIso - The ISO instant to resolve. * @param {string} timezone - An IANA timezone name, `'UTC'`, or a fixed offset. + * * @returns {string} The resolved `YYYY-MM-DD` calendar day. */ private calendarDateIn(instantIso: string, timezone: string): string { diff --git a/src/app/features/organization/features/maintenance-schedules/ui/tables/maintenance-schedule-table/testing/maintenance-schedule-table.component.spec.ts b/src/app/features/organization/features/maintenance-schedules/ui/tables/maintenance-schedule-table/testing/maintenance-schedule-table.component.spec.ts index ef73ca9e9..091dd26b0 100644 --- a/src/app/features/organization/features/maintenance-schedules/ui/tables/maintenance-schedule-table/testing/maintenance-schedule-table.component.spec.ts +++ b/src/app/features/organization/features/maintenance-schedules/ui/tables/maintenance-schedule-table/testing/maintenance-schedule-table.component.spec.ts @@ -33,6 +33,28 @@ describe('MaintenanceScheduleTable', () => { }> = {}, ): Promise => { fixture.componentRef.setInput('items', items); + fixture.componentRef.setInput('equipmentTypeOptions', [ + { + '@id': '/api/types/fire_extinguisher', + '@type': 'EquipmentType', + value: 'fire_extinguisher', + label: 'Fire extinguisher', + family: 'fire', + archived: false, + revision: 1, + icon: 'lucideFlame', + }, + { + '@id': '/api/types/smoke_detector', + '@type': 'EquipmentType', + value: 'smoke_detector', + label: 'Smoke detector', + family: 'fire', + archived: false, + revision: 1, + icon: 'lucideFlame', + }, + ]); fixture.componentRef.setInput('loading', overrides.loading ?? false); fixture.componentRef.setInput('canManage', overrides.canManage ?? false); fixture.componentRef.setInput('equipmentRouteBase', ['/organizations', 'org-1', 'equipments']); @@ -50,6 +72,24 @@ describe('MaintenanceScheduleTable', () => { fixture = TestBed.createComponent(MaintenanceScheduleTable); }); + it('renders a server-defined custom equipment type with its catalog label', async () => { + await render([schedule({ equipmentType: 'custom_gas_detector' })]); + fixture.componentRef.setInput('equipmentTypeOptions', [ + { + '@id': '/api/types/custom_gas_detector', + '@type': 'EquipmentType', + value: 'custom_gas_detector', + label: 'North building gas detector', + family: 'safety', + archived: false, + revision: 2, + icon: 'lucideBox', + }, + ]); + await fixture.whenStable(); + expect(root().textContent).toContain('North building gas detector'); + expect(root().textContent).not.toContain('custom gas detector'); + }); it('distinguishes an unevaluated row from a successfully evaluated schedule', async () => { await render([schedule({ evaluatedAt: null })]); diff --git a/src/app/features/organization/features/maintenance-schedules/utils/index.ts b/src/app/features/organization/features/maintenance-schedules/utils/index.ts index 923f01362..62d360797 100644 --- a/src/app/features/organization/features/maintenance-schedules/utils/index.ts +++ b/src/app/features/organization/features/maintenance-schedules/utils/index.ts @@ -1 +1,5 @@ export { iriId } from './iri-id/iri-id.utils'; +export { + maintenancePlanDateMode, + maintenancePlanDateValue, +} from './maintenance-plan-date-mode/maintenance-plan-date-mode.utils'; diff --git a/src/app/features/organization/features/maintenance-schedules/utils/maintenance-plan-date-mode/maintenance-plan-date-mode.utils.ts b/src/app/features/organization/features/maintenance-schedules/utils/maintenance-plan-date-mode/maintenance-plan-date-mode.utils.ts new file mode 100644 index 000000000..13cc5bf0e --- /dev/null +++ b/src/app/features/organization/features/maintenance-schedules/utils/maintenance-plan-date-mode/maintenance-plan-date-mode.utils.ts @@ -0,0 +1,37 @@ +import type { MaintenancePlanOutput } from '@features/organization/features/maintenance-schedules/models'; + +/** + * Function maintenancePlanDateMode + * + * @description + * Fixed calendar anchors are date-only values; historical deadlines retain their instant timezone. + * + * @access public + * @since unreleased + * + * @param {MaintenancePlanOutput} plan - Backend-owned cadence definition. + * + * @returns {'dateOnly' | 'date'} The rendering mode, without calculating any deadline. + */ +export function maintenancePlanDateMode(plan: MaintenancePlanOutput): 'dateOnly' | 'date' { + return plan.cadenceMode === 'fixed' ? 'dateOnly' : 'date'; +} + +/** + * Function maintenancePlanDateValue + * + * @description + * Preserves the calendar day written by the server for a fixed plan, including offset and DST + * changes. Historical deadlines remain instants. This adapter does not calculate any deadline. + * + * @access public + * @since unreleased + * + * @param {string} value - A server deadline with its calendar timezone offset. + * @param {MaintenancePlanOutput} plan - Backend-owned cadence definition. + * + * @returns {string} The fixed calendar date or unchanged historical instant. + */ +export function maintenancePlanDateValue(value: string, plan: MaintenancePlanOutput): string { + return plan.cadenceMode === 'fixed' ? value.split('T')[0] : value; +} diff --git a/src/app/features/organization/features/procurement/FEATURE.md b/src/app/features/organization/features/procurement/FEATURE.md new file mode 100644 index 000000000..e60fa1c64 --- /dev/null +++ b/src/app/features/organization/features/procurement/FEATURE.md @@ -0,0 +1,35 @@ +# Procurement Feature + +Procurement owns the internal supplier directory, purchase drafts and lifecycle, physical +receipts, motivated returns, and explicit individualization of received equipment into reserve. +Commercial quotations, taxes, invoices and payments remain in the ERP. + +Its route subtree is `/organizations/:organizationId/procurement`. One organization-scoped +workspace provides Suppliers, Orders and Receipts views. Authenticated and picker reads start +in the browser or on user action; no broad response is serialized for SSR. + +Quantities and internal unit costs stay exact decimal strings with six fractional digits. +The server owns currency, quantities still awaiting delivery, statuses and permissible transitions. +Cost fields require financial permissions independently from procurement rights; absence and +unknown cost remain distinct. Equipment receipts never become quantitative inventory stock. + +All existing-record writes send the displayed `If-Match: "revision-N"`. Receipt, return and +individualization commands retain their operation UUID and payload across failures and retry the +same physical declaration. Accepted writes are not canceled by navigation; late results cannot +populate another organization. Conflicts preserve the user's draft and require explicit review. +Supplier and purchase draft creations always send an operation UUID. Failed identical creations +retain it; success or a changed draft after a confirmed rejection starts a fresh operation. +A lost response retains the original creation payload until its result is recovered. +A received quantity remains retained while individualization is blocked, and reserve equipment is +created only after explicit confirmation. + +Forms own native Signal Forms state and emit validated commands; the page orchestrates navigation +and writes through the route-scoped SignalStore. Collections remain server-paginated. Supplier browsing +uses explicit active, archived and all filters; all sends archived=all while purchase pickers remain active-only. + +Public contracts are the transport DTOs in `models` and `ProcurementService` in `data-access`. +There is no root feature barrel and no external portal or cross-organization supplier sharing. + +Approved dependencies: organization access/models/ports; Equipment's public catalogue; Inventory's +public models/data-access and part/warehouse picker components. Cross-feature access uses their +published barrels only. Procurement does not mutate either owner's store or domain records directly. diff --git a/src/app/features/organization/features/procurement/data-access/index.ts b/src/app/features/organization/features/procurement/data-access/index.ts new file mode 100644 index 000000000..53d9bb0a9 --- /dev/null +++ b/src/app/features/organization/features/procurement/data-access/index.ts @@ -0,0 +1 @@ +export { ProcurementService } from './services/procurement/procurement.service'; diff --git a/src/app/features/organization/features/procurement/data-access/services/procurement/procurement.service.ts b/src/app/features/organization/features/procurement/data-access/services/procurement/procurement.service.ts new file mode 100644 index 000000000..efea4f0ab --- /dev/null +++ b/src/app/features/organization/features/procurement/data-access/services/procurement/procurement.service.ts @@ -0,0 +1,547 @@ +import { Service } from '@angular/core'; +import type { Observable } from 'rxjs'; +import { HydraApiService } from '@core/api'; +import type { HydraCollection, RequestOptions } from '@core/api/models'; +import type { + ChangeSupplierInput, + CreateSupplierInput, + SupplierOutput, + ChangePurchaseOrderInput, + CreatePurchaseOrderInput, + PurchaseOrderOutput, + ProcurementReceiptOutput, + ReceivePurchaseOrderInput, + IndividualizeReceiptInput, + ReturnProcurementReceiptInput, + ProcurementReturnOutput, + ReconcileProcurementReturnInput, +} from '@features/organization/features/procurement/models'; + +/** + * Class ProcurementService + * @class ProcurementService + * + * @description + * Exact internal procurement transport. The backend owns lifecycle, quantity bounds and finance. + * + * @since unreleased + */ +@Service() +export class ProcurementService extends HydraApiService { + //#region Methods + /** + * Method listSuppliers + * @method listSuppliers + * + * @description + * Reads the server-paginated supplier directory. + * + * @access public + * @since unreleased + * + * @param {string} organizationId - Owning organization, used for tenant authorization. + * @param {RequestOptions} options - Server-side pagination and declared filters. + * + * @returns {Observable>} Authorized server projection; failures + * propagate to the owning store. + */ + public listSuppliers( + organizationId: string, + options?: RequestOptions, + ): Observable> { + return this.getCollection( + `/api/organizations/${organizationId}/procurement/suppliers`, + options, + ); + } + + /** + * Method readSupplier + * @method readSupplier + * + * @description + * Reads a retained supplier independently of the active directory page. + * + * @access public + * @since unreleased + * + * @param {string} organizationId - Owning organization, used for tenant authorization. + * @param {string} supplierId - Displayed server resource or stable identifier. + * + * @returns {Observable} Authorized server projection; failures propagate to the + * owning store. + */ + public readSupplier(organizationId: string, supplierId: string): Observable { + return this.getOne( + `/api/organizations/${organizationId}/procurement/suppliers/${supplierId}`, + ); + } + + /** + * Method createSupplier + * @method createSupplier + * + * @description + * Creates an internal supplier with no external account. + * + * @access public + * @since unreleased + * + * @param {string} organizationId - Owning organization, used for tenant authorization. + * @param {CreateSupplierInput} input - Exact transport payload preserved unchanged for an + * identical retry. + * + * @returns {Observable} Authorized server projection; failures propagate to the + * owning store. + */ + public createSupplier( + organizationId: string, + input: CreateSupplierInput, + ): Observable { + return this.post( + `/api/organizations/${organizationId}/procurement/suppliers`, + input, + ); + } + + /** + * Method updateSupplier + * @method updateSupplier + * + * @description + * Changes supplier fields against the displayed revision. + * + * @access public + * @since unreleased + * + * @param {string} organizationId - Owning organization, used for tenant authorization. + * @param {SupplierOutput} supplier - Displayed server resource or stable identifier. + * @param {ChangeSupplierInput} input - Exact transport payload preserved unchanged for an + * identical retry. + * + * @returns {Observable} Authorized server projection; failures propagate to the + * owning store. + */ + public updateSupplier( + organizationId: string, + supplier: SupplierOutput, + input: ChangeSupplierInput, + ): Observable { + return this.patch( + `/api/organizations/${organizationId}/procurement/suppliers/${supplier.id}`, + input, + { headers: { 'If-Match': `"revision-${supplier.revision}"` } }, + ); + } + + /** + * Method archiveSupplier + * @method archiveSupplier + * + * @description + * Archives a supplier while retaining previous purchase records. + * + * @access public + * @since unreleased + * + * @param {string} organizationId - Owning organization, used for tenant authorization. + * @param {SupplierOutput} supplier - Displayed server resource or stable identifier. + * + * @returns {Observable} Authorized server projection; failures propagate to the + * owning store. + */ + public archiveSupplier( + organizationId: string, + supplier: SupplierOutput, + ): Observable { + return this.post, SupplierOutput>( + `/api/organizations/${organizationId}/procurement/suppliers/${supplier.id}/archive`, + {}, + { headers: { 'If-Match': `"revision-${supplier.revision}"` } }, + ); + } + + /** + * Method listOrders + * @method listOrders + * + * @description + * Reads the server-paginated purchase order lifecycle. + * + * @access public + * @since unreleased + * + * @param {string} organizationId - Owning organization, used for tenant authorization. + * @param {RequestOptions} options - Server-side pagination and declared filters. + * + * @returns {Observable>} Authorized server projection; + * failures propagate to the owning store. + */ + public listOrders( + organizationId: string, + options?: RequestOptions, + ): Observable> { + return this.getCollection( + `/api/organizations/${organizationId}/procurement/orders`, + options, + ); + } + + /** + * Method readOrder + * @method readOrder + * + * @description + * Reads the selected order with its current server quantities and revision. + * + * @access public + * @since unreleased + * + * @param {string} organizationId - Owning organization, used for tenant authorization. + * @param {string} orderId - Displayed server resource or stable identifier. + * + * @returns {Observable} Authorized server projection; failures propagate to + * the owning store. + */ + public readOrder(organizationId: string, orderId: string): Observable { + return this.getOne( + `/api/organizations/${organizationId}/procurement/orders/${orderId}`, + ); + } + + /** + * Method createOrder + * @method createOrder + * + * @description + * Creates an internal draft with exact quantities and optional authorized costs. + * + * @access public + * @since unreleased + * + * @param {string} organizationId - Owning organization, used for tenant authorization. + * @param {CreatePurchaseOrderInput} input - Exact transport payload preserved unchanged for an + * identical retry. + * + * @returns {Observable} Authorized server projection; failures propagate to + * the owning store. + */ + public createOrder( + organizationId: string, + input: CreatePurchaseOrderInput, + ): Observable { + return this.post( + `/api/organizations/${organizationId}/procurement/orders`, + input, + ); + } + + /** + * Method updateOrder + * @method updateOrder + * + * @description + * Changes an order draft against the revision the user reviewed. + * + * @access public + * @since unreleased + * + * @param {string} organizationId - Owning organization, used for tenant authorization. + * @param {PurchaseOrderOutput} order - Displayed server resource or stable identifier. + * @param {ChangePurchaseOrderInput} input - Exact transport payload preserved unchanged for an + * identical retry. + * + * @returns {Observable} Authorized server projection; failures propagate to + * the owning store. + */ + public updateOrder( + organizationId: string, + order: PurchaseOrderOutput, + input: ChangePurchaseOrderInput, + ): Observable { + return this.patch( + `/api/organizations/${organizationId}/procurement/orders/${order.id}`, + input, + { headers: { 'If-Match': `"revision-${order.revision}"` } }, + ); + } + + /** + * Method placeOrder + * @method placeOrder + * + * @description + * Explicitly places an order without implying any physical receipt. + * + * @access public + * @since unreleased + * + * @param {string} organizationId - Owning organization, used for tenant authorization. + * @param {PurchaseOrderOutput} order - Displayed server resource or stable identifier. + * + * @returns {Observable} Authorized server projection; failures propagate to + * the owning store. + */ + public placeOrder( + organizationId: string, + order: PurchaseOrderOutput, + ): Observable { + return this.post, PurchaseOrderOutput>( + `/api/organizations/${organizationId}/procurement/orders/${order.id}/order`, + {}, + { headers: { 'If-Match': `"revision-${order.revision}"` } }, + ); + } + + /** + * Method cancelRemaining + * @method cancelRemaining + * + * @description + * Cancels only undelivered quantities and keeps physical history. + * + * @access public + * @since unreleased + * + * @param {string} organizationId - Owning organization, used for tenant authorization. + * @param {PurchaseOrderOutput} order - Displayed server resource or stable identifier. + * + * @returns {Observable} Authorized server projection; failures propagate to + * the owning store. + */ + public cancelRemaining( + organizationId: string, + order: PurchaseOrderOutput, + ): Observable { + return this.post, PurchaseOrderOutput>( + `/api/organizations/${organizationId}/procurement/orders/${order.id}/cancel-remaining`, + {}, + { headers: { 'If-Match': `"revision-${order.revision}"` } }, + ); + } + + /** + * Method listReceipts + * @method listReceipts + * + * @description + * Reads physical receipts linked to a selected source order. + * + * @access public + * @since unreleased + * + * @param {string} organizationId - Owning organization, used for tenant authorization. + * @param {string} orderId - Displayed server resource or stable identifier. + * @param {RequestOptions} options - Server-side pagination and declared filters. + * + * @returns {Observable>} Authorized server projection; + * failures propagate to the owning store. + */ + public listReceipts( + organizationId: string, + orderId: string, + options?: RequestOptions, + ): Observable> { + return this.getCollection( + `/api/organizations/${organizationId}/procurement/orders/${orderId}/receipts`, + options, + ); + } + + /** + * Method readReceipt + * @method readReceipt + * + * @description + * Reads a retained receipt and its reserve equipment or stock movement. + * + * @access public + * @since unreleased + * + * @param {string} organizationId - Owning organization, used for tenant authorization. + * @param {string} receiptId - Displayed server resource or stable identifier. + * + * @returns {Observable} Authorized server projection; failures + * propagate to the owning store. + */ + public readReceipt( + organizationId: string, + receiptId: string, + ): Observable { + return this.getOne( + `/api/organizations/${organizationId}/procurement/receipts/${receiptId}`, + ); + } + + /** + * Method receiveOrder + * @method receiveOrder + * + * @description + * Records one physical delivery atomically using its stable operation UUID. + * + * @access public + * @since unreleased + * + * @param {string} organizationId - Owning organization, used for tenant authorization. + * @param {PurchaseOrderOutput} order - Displayed server resource or stable identifier. + * @param {ReceivePurchaseOrderInput} input - Exact transport payload preserved unchanged for an + * identical retry. + * + * @returns {Observable} Authorized server projection; failures + * propagate to the owning store. + */ + public receiveOrder( + organizationId: string, + order: PurchaseOrderOutput, + input: ReceivePurchaseOrderInput, + ): Observable { + return this.post( + `/api/organizations/${organizationId}/procurement/orders/${order.id}/receipts`, + input, + { headers: { 'If-Match': `"revision-${order.revision}"` } }, + ); + } + + /** + * Method individualizeReceipt + * @method individualizeReceipt + * + * @description + * Confirms reserve equipment creation without duplicating replayed units. + * + * @access public + * @since unreleased + * + * @param {string} organizationId - Owning organization, used for tenant authorization. + * @param {ProcurementReceiptOutput} receipt - Displayed server resource or stable identifier. + * @param {IndividualizeReceiptInput} input - Exact transport payload preserved unchanged for an + * identical retry. + * + * @returns {Observable} Authorized server projection; failures + * propagate to the owning store. + */ + public individualizeReceipt( + organizationId: string, + receipt: ProcurementReceiptOutput, + input: IndividualizeReceiptInput, + ): Observable { + return this.post( + `/api/organizations/${organizationId}/procurement/receipts/${receipt.id}/individualize`, + input, + { headers: { 'If-Match': `"revision-${receipt.revision}"` } }, + ); + } + + /** + * Method returnReceipt + * @method returnReceipt + * + * @description + * Records a motivated return against its retained physical receipt. + * + * @access public + * @since unreleased + * + * @param {string} organizationId - Owning organization, used for tenant authorization. + * @param {ProcurementReceiptOutput} receipt - Displayed server resource or stable identifier. + * @param {ReturnProcurementReceiptInput} input - Exact transport payload preserved unchanged for + * an identical retry. + * + * @returns {Observable} Authorized server projection; failures + * propagate to the owning store. + */ + public returnReceipt( + organizationId: string, + receipt: ProcurementReceiptOutput, + input: ReturnProcurementReceiptInput, + ): Observable { + return this.post( + `/api/organizations/${organizationId}/procurement/receipts/${receipt.id}/returns`, + input, + { headers: { 'If-Match': `"revision-${receipt.revision}"` } }, + ); + } + + /** + * Method listReturns + * @method listReturns + * + * @description + * Reads physical returns without hiding stock reconciliation still to perform. + * + * @access public + * @since unreleased + * + * @param {string} organizationId - Owning organization authority for the request. + * @param {string} receiptId - Retained physical source receipt UUID. + * @param {RequestOptions} options - Server-side pagination and authorized query filters. + * + * @returns {Observable>} Authorized server projection; + * failures propagate to the owning store. + */ + public listReturns( + organizationId: string, + receiptId: string, + options?: RequestOptions, + ): Observable> { + return this.getCollection( + `/api/organizations/${organizationId}/procurement/receipts/${receiptId}/returns`, + options, + ); + } + + /** + * Method readReturn + * @method readReturn + * + * @description + * Reads one retained immutable physical return and its latest reconciliation revision. + * + * @access public + * @since unreleased + * + * @param {string} organizationId - Owning organization authority for the request. + * @param {string} returnId - Retained physical return UUID. + * + * @returns {Observable} Authorized server projection; failures propagate + * to the owning store. + */ + public readReturn(organizationId: string, returnId: string): Observable { + return this.getOne( + `/api/organizations/${organizationId}/procurement/returns/${returnId}`, + ); + } + + /** + * Method reconcileReturn + * @method reconcileReturn + * + * @description + * Explicitly retries the inventory movement; the original quantity and reason remain retained. + * + * @access public + * @since unreleased + * + * @param {string} organizationId - Owning organization authority for the request. + * @param {ProcurementReturnOutput} returned - Displayed physical return with its current + * reconciliation revision. + * @param {ReconcileProcurementReturnInput} input - Exact transport payload preserved unchanged + * for an identical retry. + * + * @returns {Observable} Authorized server projection; failures propagate + * to the owning store. + */ + public reconcileReturn( + organizationId: string, + returned: ProcurementReturnOutput, + input: ReconcileProcurementReturnInput, + ): Observable { + return this.post( + `/api/organizations/${organizationId}/procurement/returns/${returned.id}/reconcile`, + input, + { headers: { 'If-Match': `"revision-${returned.revision}"` } }, + ); + } + //#endregion +} diff --git a/src/app/features/organization/features/procurement/data-access/services/procurement/testing/procurement.service.spec.ts b/src/app/features/organization/features/procurement/data-access/services/procurement/testing/procurement.service.spec.ts new file mode 100644 index 000000000..e63b45f5f --- /dev/null +++ b/src/app/features/organization/features/procurement/data-access/services/procurement/testing/procurement.service.spec.ts @@ -0,0 +1,304 @@ +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { ENV_CONFIG } from '@core/config/environment/env.token'; +import type { + SupplierOutput, + PurchaseOrderOutput, + ProcurementReceiptOutput, + ProcurementReturnOutput, + ReceivePurchaseOrderInput, + ReturnProcurementReceiptInput, + CreateSupplierInput, + CreatePurchaseOrderInput, +} from '@features/organization/features/procurement/models'; +import { ProcurementService } from '../procurement.service'; + +const supplier: SupplierOutput = { + '@id': '/suppliers/supplier', + '@type': 'Procurement', + id: 'supplier', + organizationId: 'org', + name: 'Fire supplies', + contacts: [], + revision: 3, + createdAt: '2026-10-05T10:00:00Z', + updatedAt: '2026-10-05T10:00:00Z', + replayed: false, +}; +const order: PurchaseOrderOutput = { + '@id': '/orders/order', + '@type': 'Procurement', + id: 'order', + organizationId: 'org', + supplierId: 'supplier', + name: 'Consumables and reserve equipment', + currency: 'EUR', + status: 'ordered', + lines: [], + financialVisible: false, + revision: 7, + createdAt: '2026-10-05T10:00:00Z', + updatedAt: '2026-10-05T10:00:00Z', + replayed: false, +}; +const receipt: ProcurementReceiptOutput = { + '@id': '/receipts/receipt', + '@type': 'Procurement', + id: 'receipt', + organizationId: 'org', + orderId: 'order', + lineId: 'line', + kind: 'part', + quantity: '0.250000', + warehouseId: 'warehouse', + currency: 'EUR', + receivedAt: '2026-10-05T10:00:00Z', + createdAt: '2026-10-05T10:00:00Z', + inventoryMovementId: 'movement', + equipmentIds: [], + returnedQuantity: '0.000000', + pendingReturnQuantity: '0.000000', + status: 'stock_received', + revision: 2, + financialVisible: false, + replayed: false, +}; + +const returned: ProcurementReturnOutput = { + '@id': '/returns/returned', + '@type': 'ProcurementReturn', + id: 'returned', + organizationId: 'org', + receiptId: 'receipt', + clientOperationId: 'd3b2f4b4-fb84-458b-8e65-e340d84ae97f', + quantity: '0.050000', + reason: 'Damaged packaging', + status: 'awaiting_reconciliation', + inventoryMovementId: null, + blockedReason: 'insufficient_stock', + createdAt: '2026-10-05T10:00:00Z', + reconciledAt: null, + revision: 4, + replayed: false, +}; + +describe('ProcurementService', () => { + let service: ProcurementService; + let http: HttpTestingController; + const url = 'https://api.test/api/organizations/org/procurement'; + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + ProcurementService, + { provide: ENV_CONFIG, useValue: { apiUrl: 'https://api.test' } }, + ], + }); + service = TestBed.inject(ProcurementService); + http = TestBed.inject(HttpTestingController); + }); + afterEach(() => http.verify()); + it('delegates directory filters, order status and receipt source pagination to the API', () => { + service + .listSuppliers('org', { + search: 'fire', + page: 2, + itemsPerPage: 30, + params: { archived: false }, + }) + .subscribe(); + const suppliers = http.expectOne((req) => req.url === url + '/suppliers'); + expect(suppliers.request.params.get('search')).toBe('fire'); + expect(suppliers.request.params.get('archived')).toBe('false'); + expect(suppliers.request.params.get('page')).toBe('2'); + suppliers.flush({ member: [], totalItems: 0 }); + service + .listOrders('org', { params: { status: 'partial_received', supplierId: 'supplier' } }) + .subscribe(); + const orders = http.expectOne((req) => req.url === url + '/orders'); + expect(orders.request.params.get('status')).toBe('partial_received'); + orders.flush({ member: [], totalItems: 0 }); + service.listReceipts('org', 'order', { page: 3 }).subscribe(); + const receipts = http.expectOne((req) => req.url === url + '/orders/order/receipts'); + expect(receipts.request.params.get('page')).toBe('3'); + receipts.flush({ member: [], totalItems: 0 }); + }); + it('serializes the explicit all archive state without treating it as an omitted filter', () => { + service.listSuppliers('org', { page: 2, params: { archived: 'all' } }).subscribe(); + const request = http.expectOne((req) => req.url === url + '/suppliers'); + expect(request.request.params.get('archived')).toBe('all'); + expect(request.request.params.get('page')).toBe('2'); + request.flush({ member: [], totalItems: 0 }); + }); + + it('uses retained detail identifiers including archived suppliers', () => { + service.readSupplier('org', 'supplier').subscribe(); + http.expectOne(url + '/suppliers/supplier').flush(supplier); + service.readOrder('org', 'order').subscribe(); + http.expectOne(url + '/orders/order').flush(order); + service.readReceipt('org', 'receipt').subscribe(); + http.expectOne(url + '/receipts/receipt').flush(receipt); + }); + it('creates internal drafts with exact decimal strings and no commercial currency or billing fields', () => { + service + .createSupplier('org', { name: 'Fire supplies', contacts: [{ name: 'Mary', role: 'Parts' }] }) + .subscribe(); + const supplierRequest = http.expectOne(url + '/suppliers'); + expect(supplierRequest.request.body.contacts).toEqual([{ name: 'Mary', role: 'Parts' }]); + supplierRequest.flush(supplier); + const input = { + name: 'Parts order', + supplierId: 'supplier', + lines: [{ kind: 'part' as const, partId: 'part', quantity: '0.250000' }], + }; + service.createOrder('org', input).subscribe(); + const request = http.expectOne(url + '/orders'); + expect(request.request.method).toBe('POST'); + expect(request.request.body).toEqual(input); + expect(request.request.body.lines[0].unitCost).toBeUndefined(); + expect(request.request.body.currency).toBeUndefined(); + request.flush(order); + }); + it('forwards creation operation UUIDs unchanged for supplier and purchase draft replay', () => { + const supplierInput: CreateSupplierInput = { + name: 'Fire supplies', + contacts: [{ name: 'Mary', role: 'Parts' }], + clientOperationId: '7d6359c7-3d49-42b0-8ac6-a5f8e16c6ba9', + }; + service.createSupplier('org', supplierInput).subscribe(); + const supplierRequest = http.expectOne(url + '/suppliers'); + expect(supplierRequest.request.method).toBe('POST'); + expect(supplierRequest.request.body).toEqual(supplierInput); + expect(supplierRequest.request.headers.has('If-Match')).toBe(false); + supplierRequest.flush(supplier); + + const orderInput: CreatePurchaseOrderInput = { + name: 'Parts order', + supplierId: 'supplier', + lines: [{ kind: 'part', partId: 'part', quantity: '0.250000' }], + clientOperationId: '65077527-2e31-420b-82d9-3ad02c5d8988', + }; + service.createOrder('org', orderInput).subscribe(); + const orderRequest = http.expectOne(url + '/orders'); + expect(orderRequest.request.method).toBe('POST'); + expect(orderRequest.request.body).toEqual(orderInput); + expect(orderRequest.request.headers.has('If-Match')).toBe(false); + orderRequest.flush(order); + }); + + it('guards supplier and purchase lifecycle changes with their displayed quoted revision', () => { + service + .updateSupplier('org', supplier, { name: 'Changed', email: null, contacts: [] }) + .subscribe(); + const updateSupplier = http.expectOne(url + '/suppliers/supplier'); + expect(updateSupplier.request.headers.get('If-Match')).toBe('"revision-3"'); + expect(updateSupplier.request.headers.get('Content-Type')).toBe('application/merge-patch+json'); + updateSupplier.flush(supplier); + service.archiveSupplier('org', supplier).subscribe(); + const archive = http.expectOne(url + '/suppliers/supplier/archive'); + expect(archive.request.method).toBe('POST'); + expect(archive.request.headers.get('If-Match')).toBe('"revision-3"'); + archive.flush(supplier); + service + .updateOrder('org', order, { name: 'Changed draft', supplierId: 'supplier', lines: [] }) + .subscribe(); + const change = http.expectOne(url + '/orders/order'); + expect(change.request.headers.get('If-Match')).toBe('"revision-7"'); + change.flush(order); + service.placeOrder('org', order).subscribe(); + const place = http.expectOne(url + '/orders/order/order'); + expect(place.request.headers.get('If-Match')).toBe('"revision-7"'); + place.flush(order); + service.cancelRemaining('org', order).subscribe(); + const cancel = http.expectOne(url + '/orders/order/cancel-remaining'); + expect(cancel.request.headers.get('If-Match')).toBe('"revision-7"'); + cancel.flush(order); + }); + it('transmits physical declarations unchanged, retaining six decimals and operation UUIDs', () => { + const input: ReceivePurchaseOrderInput = { + lineId: 'line', + warehouseId: 'warehouse', + quantity: '0.250000', + receivedAt: '2026-10-05T12:00:00+02:00', + clientOperationId: '70ecb419-6dfb-4399-9d06-3d9e9540c053', + }; + service.receiveOrder('org', order, input).subscribe(); + const receive = http.expectOne(url + '/orders/order/receipts'); + expect(receive.request.headers.get('If-Match')).toBe('"revision-7"'); + expect(receive.request.body).toEqual(input); + expect(receive.request.withCredentials).toBe(true); + receive.flush(receipt); + const individualize = { clientOperationId: 'b6eb7cd7-22f4-4c60-8f2c-09f5a2aa5bc6' }; + service.individualizeReceipt('org', receipt, individualize).subscribe(); + const units = http.expectOne(url + '/receipts/receipt/individualize'); + expect(units.request.headers.get('If-Match')).toBe('"revision-2"'); + expect(units.request.body).toEqual(individualize); + units.flush(receipt); + const returnInput: ReturnProcurementReceiptInput = { + quantity: '0.050000', + reason: 'Damaged packaging', + clientOperationId: 'd3b2f4b4-fb84-458b-8e65-e340d84ae97f', + }; + service.returnReceipt('org', receipt, returnInput).subscribe(); + const physicalReturn = http.expectOne(url + '/receipts/receipt/returns'); + expect(physicalReturn.request.headers.get('If-Match')).toBe('"revision-2"'); + expect(physicalReturn.request.body).toEqual(returnInput); + physicalReturn.flush(receipt); + }); + it('propagates revision conflicts without replacing the original declaration', () => { + let failure: unknown; + service.cancelRemaining('org', order).subscribe({ + error: (error: unknown) => { + failure = error; + }, + }); + http + .expectOne(url + '/orders/order/cancel-remaining') + .flush( + { title: 'Revision conflict', detail: 'Review the latest purchase.' }, + { status: 412, statusText: 'Precondition Failed' }, + ); + expect(failure).toMatchObject({ status: 412, detail: 'Review the latest purchase.' }); + }); + it('reads the retained return history using source receipt and server pagination', () => { + const next = vi.fn(); + service.listReturns('org', 'receipt', { page: 2, itemsPerPage: 30 }).subscribe(next); + const list = http.expectOne((req) => req.url === url + '/receipts/receipt/returns'); + expect(list.request.method).toBe('GET'); + expect(list.request.params.get('page')).toBe('2'); + expect(list.request.params.get('itemsPerPage')).toBe('30'); + list.flush({ member: [returned], totalItems: 31 }); + expect(next).toHaveBeenCalledWith( + expect.objectContaining({ member: [returned], totalItems: 31 }), + ); + service.readReturn('org', 'returned').subscribe(); + const detail = http.expectOne(url + '/returns/returned'); + expect(detail.request.method).toBe('GET'); + detail.flush(returned); + }); + + it('reconciles the original physical return with its displayed revision and a distinct attempt UUID', () => { + const input = { clientOperationId: '722df8a7-b9a6-4e19-a10a-6c52f2e7a01d' }; + const confirmed = { + ...returned, + status: 'confirmed' as const, + revision: 5, + inventoryMovementId: 'returned-movement', + reconciledAt: '2026-10-06T10:00:00Z', + }; + const next = vi.fn(); + service.reconcileReturn('org', returned, input).subscribe(next); + const request = http.expectOne(url + '/returns/returned/reconcile'); + expect(request.request.method).toBe('POST'); + expect(request.request.headers.get('If-Match')).toBe('"revision-4"'); + expect(request.request.body).toEqual(input); + expect(request.request.withCredentials).toBe(true); + request.flush(confirmed); + expect(next).toHaveBeenCalledExactlyOnceWith(confirmed); + expect(confirmed.clientOperationId).toBe(returned.clientOperationId); + expect(confirmed.quantity).toBe(returned.quantity); + expect(confirmed.reason).toBe(returned.reason); + }); +}); diff --git a/src/app/features/organization/features/procurement/models/index.ts b/src/app/features/organization/features/procurement/models/index.ts new file mode 100644 index 000000000..c4b017528 --- /dev/null +++ b/src/app/features/organization/features/procurement/models/index.ts @@ -0,0 +1,18 @@ +export type { SupplierContact } from './supplier/supplier-contact.interface'; +export type { ChangeSupplierInput } from './supplier/change-supplier-input.interface'; +export type { CreateSupplierInput } from './supplier/create-supplier-input.interface'; +export type { SupplierOutput } from './supplier/supplier-output.interface'; +export type { ProcurementLineKind } from './purchase-order/procurement-line-kind.type'; +export type { PurchaseOrderStatus } from './purchase-order/purchase-order-status.type'; +export type { PurchaseOrderLineInput } from './purchase-order/purchase-order-line-input.interface'; +export type { PurchaseOrderLineOutput } from './purchase-order/purchase-order-line-output.interface'; +export type { ChangePurchaseOrderInput } from './purchase-order/change-purchase-order-input.interface'; +export type { CreatePurchaseOrderInput } from './purchase-order/create-purchase-order-input.interface'; +export type { PurchaseOrderOutput } from './purchase-order/purchase-order-output.interface'; +export type { ProcurementReceiptStatus } from './receipt/procurement-receipt-status.type'; +export type { ProcurementReceiptOutput } from './receipt/procurement-receipt-output.interface'; +export type { ReceivePurchaseOrderInput } from './receipt/receive-purchase-order-input.interface'; +export type { IndividualizeReceiptInput } from './receipt/individualize-receipt-input.interface'; +export type { ReturnProcurementReceiptInput } from './receipt/return-procurement-receipt-input.interface'; +export type { ProcurementReturnOutput } from './receipt/procurement-return-output.interface'; +export type { ReconcileProcurementReturnInput } from './receipt/reconcile-procurement-return-input.interface'; diff --git a/src/app/features/organization/features/procurement/models/procurement-tag/procurement-tag.util.ts b/src/app/features/organization/features/procurement/models/procurement-tag/procurement-tag.util.ts new file mode 100644 index 000000000..ecba8b034 --- /dev/null +++ b/src/app/features/organization/features/procurement/models/procurement-tag/procurement-tag.util.ts @@ -0,0 +1,58 @@ +/** + * Constant ORDER_LABELS + * + * @description + * Native order labels resolve backend lifecycle values without branching in a template. + * + * @since unreleased + */ +const ORDER_LABELS: Readonly> = { + draft: $localize`:@@procurement.status.draft:Draft`, + ordered: $localize`:@@procurement.status.ordered:Ordered`, + partial_received: $localize`:@@procurement.status.partialReceived:Partially received`, + received: $localize`:@@procurement.status.received:Received`, + cancelled: $localize`:@@procurement.status.cancelled:Remaining quantities cancelled`, +}; +/** + * Constant RECEIPT_LABELS + * + * @description + * Physical goods remain visible even while reserve equipment cannot yet be created. + * + * @since unreleased + */ +const RECEIPT_LABELS: Readonly> = { + awaiting_individualization: $localize`:@@procurement.status.awaiting:Awaiting reserve equipment`, + individualized: $localize`:@@procurement.status.individualized:Reserve equipment created`, + stock_received: $localize`:@@procurement.status.stockReceived:Stock received`, + returned: $localize`:@@procurement.status.returned:Returned`, +}; +/** + * Function procurementStatusLabel + * + * @description + * Resolves server status into a readable translated label with a forward-compatible fallback. + * + * @access public + * @since unreleased + * + * @param {'order' | 'receipt' | 'return'} family - Resource lifecycle. + * @param {string} value - Server status. + * + * @returns {string} Translated status label. + */ +export function procurementStatusLabel( + family: 'order' | 'receipt' | 'return', + value: string, +): string { + const labels = + family === 'order' + ? ORDER_LABELS + : family === 'receipt' + ? RECEIPT_LABELS + : { + awaiting_reconciliation: $localize`:@@procurement.status.awaitingReconciliation:Awaiting inventory reconciliation`, + confirmed: $localize`:@@procurement.status.returnConfirmed:Inventory reversal confirmed`, + }; + return labels[value as keyof typeof labels] ?? value.replaceAll('_', ' '); +} diff --git a/src/app/features/organization/features/procurement/models/purchase-order/change-purchase-order-input.interface.ts b/src/app/features/organization/features/procurement/models/purchase-order/change-purchase-order-input.interface.ts new file mode 100644 index 000000000..02fe328b9 --- /dev/null +++ b/src/app/features/organization/features/procurement/models/purchase-order/change-purchase-order-input.interface.ts @@ -0,0 +1,48 @@ +import type { PurchaseOrderLineInput } from './purchase-order-line-input.interface'; +/** + * Interface ChangePurchaseOrderInput + * @interface ChangePurchaseOrderInput + * + * @description + * Internal purchase draft without taxes, payments or commercial prices. + */ +export interface ChangePurchaseOrderInput { + /** + * Property name + * @readonly + * + * @description + * Human-readable purchase reference. + * + * @access public + * + * @type {string} + */ + readonly name: string; + + /** + * Property supplierId + * @readonly + * + * @description + * Active internal supplier UUID. + * + * @access public + * + * @type {string} + */ + readonly supplierId: string; + + /** + * Property lines + * @readonly + * + * @description + * Bounded draft lines retained until the server accepts the change. + * + * @access public + * + * @type {readonly PurchaseOrderLineInput[]} + */ + readonly lines: readonly PurchaseOrderLineInput[]; +} diff --git a/src/app/features/organization/features/procurement/models/purchase-order/create-purchase-order-input.interface.ts b/src/app/features/organization/features/procurement/models/purchase-order/create-purchase-order-input.interface.ts new file mode 100644 index 000000000..6fa824cde --- /dev/null +++ b/src/app/features/organization/features/procurement/models/purchase-order/create-purchase-order-input.interface.ts @@ -0,0 +1,23 @@ +import type { ChangePurchaseOrderInput } from './change-purchase-order-input.interface'; + +/** + * Interface CreatePurchaseOrderInput + * @interface CreatePurchaseOrderInput + * + * @description + * Internal purchase draft creation with an optional operation UUID for safe transport replay. + */ +export interface CreatePurchaseOrderInput extends ChangePurchaseOrderInput { + /** + * Property clientOperationId + * @readonly + * + * @description + * Stable command UUID retained when retrying the same purchase draft creation. + * + * @access public + * + * @type {string} + */ + readonly clientOperationId?: string; +} diff --git a/src/app/features/organization/features/procurement/models/purchase-order/procurement-line-kind.type.ts b/src/app/features/organization/features/procurement/models/purchase-order/procurement-line-kind.type.ts new file mode 100644 index 000000000..8824ed288 --- /dev/null +++ b/src/app/features/organization/features/procurement/models/purchase-order/procurement-line-kind.type.ts @@ -0,0 +1,9 @@ +/** + * Type ProcurementLineKind + * + * @description + * Consumable articles and equipment requiring individual park identities. + * + * @type {ProcurementLineKind} + */ +export type ProcurementLineKind = 'part' | 'equipment_to_individualize'; diff --git a/src/app/features/organization/features/procurement/models/purchase-order/purchase-order-line-input.interface.ts b/src/app/features/organization/features/procurement/models/purchase-order/purchase-order-line-input.interface.ts new file mode 100644 index 000000000..701fa8472 --- /dev/null +++ b/src/app/features/organization/features/procurement/models/purchase-order/purchase-order-line-input.interface.ts @@ -0,0 +1,100 @@ +import type { ProcurementLineKind } from './procurement-line-kind.type'; +/** + * Interface PurchaseOrderLineInput + * @interface PurchaseOrderLineInput + * + * @description + * Exact draft line, keeping stock articles distinct from individual equipment. + */ +export interface PurchaseOrderLineInput { + /** + * Property id + * @readonly + * + * @description + * Existing stable line UUID, or omitted for server allocation. + * + * @access public + * + * @type {string} + */ + readonly id?: string; + + /** + * Property kind + * @readonly + * + * @description + * The line's stock or park responsibility. + * + * @access public + * + * @type {ProcurementLineKind} + */ + readonly kind: ProcurementLineKind; + + /** + * Property partId + * @readonly + * + * @description + * Article UUID for a stock line only. + * + * @access public + * + * @type {string | null} + */ + readonly partId?: string | null; + + /** + * Property typeCode + * @readonly + * + * @description + * Server catalogue code for an equipment line only. + * + * @access public + * + * @type {string | null} + */ + readonly typeCode?: string | null; + + /** + * Property identityTemplate + * @readonly + * + * @description + * Declarative equipment identity copied when reserve units are created. + * + * @access public + * + * @type {Readonly>} + */ + readonly identityTemplate?: Readonly>; + + /** + * Property quantity + * @readonly + * + * @description + * Positive decimal string with six fractional digits; whole units for equipment. + * + * @access public + * + * @type {string} + */ + readonly quantity: string; + + /** + * Property unitCost + * @readonly + * + * @description + * Optional exact six-digit internal cost; only financial managers may send it. + * + * @access public + * + * @type {string | null} + */ + readonly unitCost?: string | null; +} diff --git a/src/app/features/organization/features/procurement/models/purchase-order/purchase-order-line-output.interface.ts b/src/app/features/organization/features/procurement/models/purchase-order/purchase-order-line-output.interface.ts new file mode 100644 index 000000000..7052dd3c0 --- /dev/null +++ b/src/app/features/organization/features/procurement/models/purchase-order/purchase-order-line-output.interface.ts @@ -0,0 +1,178 @@ +import type { ProcurementLineKind } from './procurement-line-kind.type'; +/** + * Interface PurchaseOrderLineOutput + * @interface PurchaseOrderLineOutput + * + * @description + * Retained line quantities and authorized financial visibility. + */ +export interface PurchaseOrderLineOutput { + /** + * Property id + * @readonly + * + * @description + * Stable order-line UUID. + * + * @access public + * + * @type {string} + */ + readonly id: string; + + /** + * Property kind + * @readonly + * + * @description + * Stock article or individual equipment responsibility. + * + * @access public + * + * @type {ProcurementLineKind} + */ + readonly kind: ProcurementLineKind; + + /** + * Property partId + * @readonly + * + * @description + * Article UUID for a stock line. + * + * @access public + * + * @type {string | null} + */ + readonly partId?: string | null; + + /** + * Property partCode + * @readonly + * + * @description + * Internal article code retained when the order line was prepared. + * + * @access public + * + * @type {string | null} + */ + readonly partCode?: string | null; + + /** + * Property partLabel + * @readonly + * + * @description + * Internal article label retained independently from later catalogue changes. + * + * @access public + * + * @type {string | null} + */ + readonly partLabel?: string | null; + + /** + * Property partUnit + * @readonly + * + * @description + * Original stock article unit; absent only for unmatched historical references. + * + * @access public + * + * @type {string | null} + */ + readonly partUnit?: string | null; + + /** + * Property typeCode + * @readonly + * + * @description + * Server catalogue equipment code. + * + * @access public + * + * @type {string | null} + */ + readonly typeCode?: string | null; + + /** + * Property identityTemplate + * @readonly + * + * @description + * Retained declarative equipment identity; the server may encode empty values as an array. + * + * @access public + * + * @type {Readonly> | readonly []} + */ + readonly identityTemplate: Readonly> | readonly []; + + /** + * Property quantity + * @readonly + * + * @description + * Ordered quantity as an exact decimal string. + * + * @access public + * + * @type {string} + */ + readonly quantity: string; + + /** + * Property receivedQuantity + * @readonly + * + * @description + * Gross physically received quantity, including subsequently returned units. + * + * @access public + * + * @type {string} + */ + readonly receivedQuantity: string; + + /** + * Property returnedQuantity + * @readonly + * + * @description + * Motivated supplier returns as an exact decimal string. + * + * @access public + * + * @type {string} + */ + readonly returnedQuantity: string; + + /** + * Property remainingQuantity + * @readonly + * + * @description + * Backend-calculated quantity still awaiting delivery. + * + * @access public + * + * @type {string} + */ + readonly remainingQuantity: string; + + /** + * Property unitCost + * @readonly + * + * @description + * Authorized exact unit cost; omitted without permission and null when unknown. + * + * @access public + * + * @type {string | null} + */ + readonly unitCost?: string | null; +} diff --git a/src/app/features/organization/features/procurement/models/purchase-order/purchase-order-output.interface.ts b/src/app/features/organization/features/procurement/models/purchase-order/purchase-order-output.interface.ts new file mode 100644 index 000000000..3883c370b --- /dev/null +++ b/src/app/features/organization/features/procurement/models/purchase-order/purchase-order-output.interface.ts @@ -0,0 +1,167 @@ +import type { HydraItem } from '@core/api/models'; +import type { PurchaseOrderLineOutput } from './purchase-order-line-output.interface'; +import type { PurchaseOrderStatus } from './purchase-order-status.type'; +/** + * Interface PurchaseOrderOutput + * @interface PurchaseOrderOutput + * + * @description + * Authorized internal purchase order with server-owned lifecycle and currency. + */ +export interface PurchaseOrderOutput extends HydraItem { + /** + * Property id + * @readonly + * + * @description + * Stable purchase UUID. + * + * @access public + * + * @type {string} + */ + readonly id: string; + + /** + * Property organizationId + * @readonly + * + * @description + * Owning tenant. + * + * @access public + * + * @type {string} + */ + readonly organizationId: string; + + /** + * Property supplierId + * @readonly + * + * @description + * Retained internal supplier UUID. + * + * @access public + * + * @type {string} + */ + readonly supplierId: string; + + /** + * Property name + * @readonly + * + * @description + * Human-readable purchase reference. + * + * @access public + * + * @type {string} + */ + readonly name: string; + + /** + * Property currency + * @readonly + * + * @description + * Organization currency frozen by the backend. + * + * @access public + * + * @type {string} + */ + readonly currency: string; + + /** + * Property status + * @readonly + * + * @description + * Server-owned lifecycle state. + * + * @access public + * + * @type {PurchaseOrderStatus} + */ + readonly status: PurchaseOrderStatus; + + /** + * Property lines + * @readonly + * + * @description + * Retained order lines and their server quantities. + * + * @access public + * + * @type {readonly PurchaseOrderLineOutput[]} + */ + readonly lines: readonly PurchaseOrderLineOutput[]; + + /** + * Property financialVisible + * @readonly + * + * @description + * Whether internal unit costs were authorized in this projection. + * + * @access public + * + * @type {boolean} + */ + readonly financialVisible: boolean; + + /** + * Property revision + * @readonly + * + * @description + * Displayed optimistic revision for mutations. + * + * @access public + * + * @type {number} + */ + readonly revision: number; + + /** + * Property createdAt + * @readonly + * + * @description + * Original creation instant. + * + * @access public + * + * @type {string} + */ + readonly createdAt: string; + + /** + * Property updatedAt + * @readonly + * + * @description + * Most recent mutation instant. + * + * @access public + * + * @type {string} + */ + readonly updatedAt: string; + + /** + * Property replayed + * @readonly + * + * @description + * Whether this result acknowledged an already accepted operation. + * + * @access public + * + * @type {boolean} + */ + readonly replayed: boolean; +} diff --git a/src/app/features/organization/features/procurement/models/purchase-order/purchase-order-status.type.ts b/src/app/features/organization/features/procurement/models/purchase-order/purchase-order-status.type.ts new file mode 100644 index 000000000..10bc88e70 --- /dev/null +++ b/src/app/features/organization/features/procurement/models/purchase-order/purchase-order-status.type.ts @@ -0,0 +1,14 @@ +/** + * Type PurchaseOrderStatus + * + * @description + * Server-owned purchase lifecycle. + * + * @type {PurchaseOrderStatus} + */ +export type PurchaseOrderStatus = + | 'draft' + | 'ordered' + | 'partial_received' + | 'received' + | 'cancelled'; diff --git a/src/app/features/organization/features/procurement/models/receipt/individualize-receipt-input.interface.ts b/src/app/features/organization/features/procurement/models/receipt/individualize-receipt-input.interface.ts new file mode 100644 index 000000000..3a8c8bfff --- /dev/null +++ b/src/app/features/organization/features/procurement/models/receipt/individualize-receipt-input.interface.ts @@ -0,0 +1,21 @@ +/** + * Interface IndividualizeReceiptInput + * @interface IndividualizeReceiptInput + * + * @description + * Explicit confirmation to create reserve equipment for received material. + */ +export interface IndividualizeReceiptInput { + /** + * Property clientOperationId + * @readonly + * + * @description + * Stable UUID retained across response-loss replay. + * + * @access public + * + * @type {string} + */ + readonly clientOperationId: string; +} diff --git a/src/app/features/organization/features/procurement/models/receipt/procurement-receipt-output.interface.ts b/src/app/features/organization/features/procurement/models/receipt/procurement-receipt-output.interface.ts new file mode 100644 index 000000000..8af11bf0c --- /dev/null +++ b/src/app/features/organization/features/procurement/models/receipt/procurement-receipt-output.interface.ts @@ -0,0 +1,271 @@ +import type { HydraItem } from '@core/api/models'; +import type { ProcurementLineKind } from '../purchase-order/procurement-line-kind.type'; +import type { ProcurementReceiptStatus } from './procurement-receipt-status.type'; +/** + * Interface ProcurementReceiptOutput + * @interface ProcurementReceiptOutput + * + * @description + * Retained physical receipt, stock movement or individual reserve equipment. + */ +export interface ProcurementReceiptOutput extends HydraItem { + /** + * Property id + * @readonly + * + * @description + * Stable receipt UUID. + * + * @access public + * + * @type {string} + */ + readonly id: string; + + /** + * Property organizationId + * @readonly + * + * @description + * Owning tenant. + * + * @access public + * + * @type {string} + */ + readonly organizationId: string; + + /** + * Property orderId + * @readonly + * + * @description + * Retained source order UUID. + * + * @access public + * + * @type {string} + */ + readonly orderId: string; + + /** + * Property lineId + * @readonly + * + * @description + * Retained source line UUID. + * + * @access public + * + * @type {string} + */ + readonly lineId: string; + + /** + * Property kind + * @readonly + * + * @description + * Stock article or equipment awaiting identities. + * + * @access public + * + * @type {ProcurementLineKind} + */ + readonly kind: ProcurementLineKind; + + /** + * Property quantity + * @readonly + * + * @description + * Received decimal quantity, kept exact. + * + * @access public + * + * @type {string} + */ + readonly quantity: string; + + /** + * Property warehouseId + * @readonly + * + * @description + * Warehouse UUID for a stock receipt only. + * + * @access public + * + * @type {string | null} + */ + readonly warehouseId?: string | null; + + /** + * Property currency + * @readonly + * + * @description + * Retained organization currency. + * + * @access public + * + * @type {string} + */ + readonly currency: string; + + /** + * Property receivedAt + * @readonly + * + * @description + * Actual physical receipt instant with explicit timezone offset. + * + * @access public + * + * @type {string} + */ + readonly receivedAt: string; + + /** + * Property createdAt + * @readonly + * + * @description + * Server recording instant. + * + * @access public + * + * @type {string} + */ + readonly createdAt: string; + + /** + * Property inventoryMovementId + * @readonly + * + * @description + * Atomic stock movement link, absent for individual equipment. + * + * @access public + * + * @type {string | null} + */ + readonly inventoryMovementId?: string | null; + + /** + * Property equipmentIds + * @readonly + * + * @description + * Reserve equipment created by confirmed individualization. + * + * @access public + * + * @type {readonly string[]} + */ + readonly equipmentIds: readonly string[]; + + /** + * Property returnedQuantity + * @readonly + * + * @description + * Motivated returned quantity, retained separately from the gross receipt. + * + * @access public + * + * @type {string} + */ + readonly returnedQuantity: string; + + /** + * Property pendingReturnQuantity + * @readonly + * + * @description + * Physical returns retained while their inventory movements remain unresolved. + * + * @access public + * + * @type {string} + */ + readonly pendingReturnQuantity: string; + + /** + * Property status + * @readonly + * + * @description + * Server-owned fulfillment state. + * + * @access public + * + * @type {ProcurementReceiptStatus} + */ + readonly status: ProcurementReceiptStatus; + + /** + * Property blockedReason + * @readonly + * + * @description + * Server reason blocking individualization; physical receipt remains retained. + * + * @access public + * + * @type {string | null} + */ + readonly blockedReason?: string | null; + + /** + * Property revision + * @readonly + * + * @description + * Displayed optimistic revision for receipt mutations. + * + * @access public + * + * @type {number} + */ + readonly revision: number; + + /** + * Property financialVisible + * @readonly + * + * @description + * Whether financial fields were authorized. + * + * @access public + * + * @type {boolean} + */ + readonly financialVisible: boolean; + + /** + * Property replayed + * @readonly + * + * @description + * Whether this result acknowledged an already accepted operation. + * + * @access public + * + * @type {boolean} + */ + readonly replayed: boolean; + + /** + * Property unitCost + * @readonly + * + * @description + * Exact internal cost; absent without permission and null when unknown. + * + * @access public + * + * @type {string | null} + */ + readonly unitCost?: string | null; +} diff --git a/src/app/features/organization/features/procurement/models/receipt/procurement-receipt-status.type.ts b/src/app/features/organization/features/procurement/models/receipt/procurement-receipt-status.type.ts new file mode 100644 index 000000000..e277e7e83 --- /dev/null +++ b/src/app/features/organization/features/procurement/models/receipt/procurement-receipt-status.type.ts @@ -0,0 +1,13 @@ +/** + * Type ProcurementReceiptStatus + * + * @description + * Physical receipts remain retained while awaiting individual park identities. + * + * @type {ProcurementReceiptStatus} + */ +export type ProcurementReceiptStatus = + | 'awaiting_individualization' + | 'individualized' + | 'stock_received' + | 'returned'; diff --git a/src/app/features/organization/features/procurement/models/receipt/procurement-return-output.interface.ts b/src/app/features/organization/features/procurement/models/receipt/procurement-return-output.interface.ts new file mode 100644 index 000000000..0996aeb73 --- /dev/null +++ b/src/app/features/organization/features/procurement/models/receipt/procurement-return-output.interface.ts @@ -0,0 +1,179 @@ +import type { HydraItem } from '@core/api/models'; + +/** + * Interface ProcurementReturnOutput + * @interface ProcurementReturnOutput + * + * @description + * Retained physical supplier return with separate inventory reconciliation. + */ +export interface ProcurementReturnOutput extends HydraItem { + /** + * Property id + * @readonly + * + * @description + * Stable physical return UUID. + * + * @access public + * + * @type {string} + */ + readonly id: string; + + /** + * Property organizationId + * @readonly + * + * @description + * Owning organization. + * + * @access public + * + * @type {string} + */ + readonly organizationId: string; + + /** + * Property receiptId + * @readonly + * + * @description + * Retained physical source receipt. + * + * @access public + * + * @type {string} + */ + readonly receiptId: string; + + /** + * Property clientOperationId + * @readonly + * + * @description + * UUID of the original physical declaration, unchanged by reconciliation attempts. + * + * @access public + * + * @type {string} + */ + readonly clientOperationId: string; + + /** + * Property quantity + * @readonly + * + * @description + * Exact physically returned quantity. + * + * @access public + * + * @type {string} + */ + readonly quantity: string; + + /** + * Property reason + * @readonly + * + * @description + * Original motivated physical return reason. + * + * @access public + * + * @type {string} + */ + readonly reason: string; + + /** + * Property status + * @readonly + * + * @description + * Inventory reconciliation status, separate from the retained physical fact. + * + * @access public + * + * @type {'awaiting_reconciliation' | 'confirmed'} + */ + readonly status: 'awaiting_reconciliation' | 'confirmed'; + + /** + * Property inventoryMovementId + * @readonly + * + * @description + * Confirmed inventory movement, absent while reconciliation is pending. + * + * @access public + * + * @type {string | null} + */ + readonly inventoryMovementId?: string | null; + + /** + * Property blockedReason + * @readonly + * + * @description + * Reason the stock movement could not yet be confirmed. + * + * @access public + * + * @type {string | null} + */ + readonly blockedReason?: string | null; + + /** + * Property createdAt + * @readonly + * + * @description + * Original declaration instant. + * + * @access public + * + * @type {string} + */ + readonly createdAt: string; + + /** + * Property reconciledAt + * @readonly + * + * @description + * Confirmation instant, absent while stock reconciliation is pending. + * + * @access public + * + * @type {string | null} + */ + readonly reconciledAt?: string | null; + + /** + * Property revision + * @readonly + * + * @description + * Displayed revision for an explicit reconciliation attempt. + * + * @access public + * + * @type {number} + */ + readonly revision: number; + + /** + * Property replayed + * @readonly + * + * @description + * Whether the current attempt was acknowledged as a replay. + * + * @access public + * + * @type {boolean} + */ + readonly replayed: boolean; +} diff --git a/src/app/features/organization/features/procurement/models/receipt/receive-purchase-order-input.interface.ts b/src/app/features/organization/features/procurement/models/receipt/receive-purchase-order-input.interface.ts new file mode 100644 index 000000000..07018f4ab --- /dev/null +++ b/src/app/features/organization/features/procurement/models/receipt/receive-purchase-order-input.interface.ts @@ -0,0 +1,73 @@ +/** + * Interface ReceivePurchaseOrderInput + * @interface ReceivePurchaseOrderInput + * + * @description + * One bounded physical delivery with a stable operation UUID for response-loss replay. + */ +export interface ReceivePurchaseOrderInput { + /** + * Property lineId + * @readonly + * + * @description + * Source order-line UUID. + * + * @access public + * + * @type {string} + */ + readonly lineId: string; + + /** + * Property warehouseId + * @readonly + * + * @description + * Active warehouse for a stock line; omitted for individual equipment. + * + * @access public + * + * @type {string | null} + */ + readonly warehouseId?: string | null; + + /** + * Property quantity + * @readonly + * + * @description + * Positive exact quantity, bounded by the source line on the server. + * + * @access public + * + * @type {string} + */ + readonly quantity: string; + + /** + * Property receivedAt + * @readonly + * + * @description + * Actual receipt instant with an explicit offset. + * + * @access public + * + * @type {string} + */ + readonly receivedAt: string; + + /** + * Property clientOperationId + * @readonly + * + * @description + * Stable UUID retained unchanged for an identical retry. + * + * @access public + * + * @type {string} + */ + readonly clientOperationId: string; +} diff --git a/src/app/features/organization/features/procurement/models/receipt/reconcile-procurement-return-input.interface.ts b/src/app/features/organization/features/procurement/models/receipt/reconcile-procurement-return-input.interface.ts new file mode 100644 index 000000000..fb77a2a3d --- /dev/null +++ b/src/app/features/organization/features/procurement/models/receipt/reconcile-procurement-return-input.interface.ts @@ -0,0 +1,21 @@ +/** + * Interface ReconcileProcurementReturnInput + * @interface ReconcileProcurementReturnInput + * + * @description + * Explicit reconciliation attempt, distinct from the immutable physical return declaration. + */ +export interface ReconcileProcurementReturnInput { + /** + * Property clientOperationId + * @readonly + * + * @description + * Stable UUID for identical retries of this reconciliation attempt. + * + * @access public + * + * @type {string} + */ + readonly clientOperationId: string; +} diff --git a/src/app/features/organization/features/procurement/models/receipt/return-procurement-receipt-input.interface.ts b/src/app/features/organization/features/procurement/models/receipt/return-procurement-receipt-input.interface.ts new file mode 100644 index 000000000..b55f1664c --- /dev/null +++ b/src/app/features/organization/features/procurement/models/receipt/return-procurement-receipt-input.interface.ts @@ -0,0 +1,47 @@ +/** + * Interface ReturnProcurementReceiptInput + * @interface ReturnProcurementReceiptInput + * + * @description + * Motivated physical return linked to the retained original receipt. + */ +export interface ReturnProcurementReceiptInput { + /** + * Property quantity + * @readonly + * + * @description + * Positive exact returned quantity. + * + * @access public + * + * @type {string} + */ + readonly quantity: string; + + /** + * Property reason + * @readonly + * + * @description + * Human-readable physical return reason. + * + * @access public + * + * @type {string} + */ + readonly reason: string; + + /** + * Property clientOperationId + * @readonly + * + * @description + * Stable UUID retained across identical retries. + * + * @access public + * + * @type {string} + */ + readonly clientOperationId: string; +} diff --git a/src/app/features/organization/features/procurement/models/supplier/change-supplier-input.interface.ts b/src/app/features/organization/features/procurement/models/supplier/change-supplier-input.interface.ts new file mode 100644 index 000000000..5321979b5 --- /dev/null +++ b/src/app/features/organization/features/procurement/models/supplier/change-supplier-input.interface.ts @@ -0,0 +1,74 @@ +import type { SupplierContact } from './supplier-contact.interface'; +/** + * Interface ChangeSupplierInput + * @interface ChangeSupplierInput + * + * @description + * Supplier fields accepted for creation and draft-preserving changes. + */ +export interface ChangeSupplierInput { + /** + * Property name + * @readonly + * + * @description + * Internal display name. + * + * @access public + * + * @type {string} + */ + readonly name: string; + + /** + * Property code + * @readonly + * + * @description + * Optional internal supplier code; null explicitly clears it. + * + * @access public + * + * @type {string | null} + */ + readonly code?: string | null; + + /** + * Property email + * @readonly + * + * @description + * Optional general contact address. + * + * @access public + * + * @type {string | null} + */ + readonly email?: string | null; + + /** + * Property phone + * @readonly + * + * @description + * Optional general telephone. + * + * @access public + * + * @type {string | null} + */ + readonly phone?: string | null; + + /** + * Property contacts + * @readonly + * + * @description + * Internal contacts, without creating an external user account. + * + * @access public + * + * @type {readonly SupplierContact[]} + */ + readonly contacts: readonly SupplierContact[]; +} diff --git a/src/app/features/organization/features/procurement/models/supplier/create-supplier-input.interface.ts b/src/app/features/organization/features/procurement/models/supplier/create-supplier-input.interface.ts new file mode 100644 index 000000000..8a593b760 --- /dev/null +++ b/src/app/features/organization/features/procurement/models/supplier/create-supplier-input.interface.ts @@ -0,0 +1,23 @@ +import type { ChangeSupplierInput } from './change-supplier-input.interface'; + +/** + * Interface CreateSupplierInput + * @interface CreateSupplierInput + * + * @description + * Internal supplier creation with an optional operation UUID for safe transport replay. + */ +export interface CreateSupplierInput extends ChangeSupplierInput { + /** + * Property clientOperationId + * @readonly + * + * @description + * Stable command UUID retained when retrying the same supplier creation. + * + * @access public + * + * @type {string} + */ + readonly clientOperationId?: string; +} diff --git a/src/app/features/organization/features/procurement/models/supplier/supplier-contact.interface.ts b/src/app/features/organization/features/procurement/models/supplier/supplier-contact.interface.ts new file mode 100644 index 000000000..879f6aecf --- /dev/null +++ b/src/app/features/organization/features/procurement/models/supplier/supplier-contact.interface.ts @@ -0,0 +1,60 @@ +/** + * Interface SupplierContact + * @interface SupplierContact + * + * @description + * Internal supplier contact retained within its organization. + */ +export interface SupplierContact { + /** + * Property name + * @readonly + * + * @description + * Contact's display name. + * + * @access public + * + * @type {string} + */ + readonly name: string; + + /** + * Property email + * @readonly + * + * @description + * Optional contact email. + * + * @access public + * + * @type {string | null} + */ + readonly email?: string | null; + + /** + * Property phone + * @readonly + * + * @description + * Optional telephone number. + * + * @access public + * + * @type {string | null} + */ + readonly phone?: string | null; + + /** + * Property role + * @readonly + * + * @description + * Optional responsibility. + * + * @access public + * + * @type {string | null} + */ + readonly role?: string | null; +} diff --git a/src/app/features/organization/features/procurement/models/supplier/supplier-output.interface.ts b/src/app/features/organization/features/procurement/models/supplier/supplier-output.interface.ts new file mode 100644 index 000000000..8bd549018 --- /dev/null +++ b/src/app/features/organization/features/procurement/models/supplier/supplier-output.interface.ts @@ -0,0 +1,166 @@ +import type { HydraItem } from '@core/api/models'; +import type { SupplierContact } from './supplier-contact.interface'; +/** + * Interface SupplierOutput + * @interface SupplierOutput + * + * @description + * Server-authorized internal supplier and displayed optimistic revision. + */ +export interface SupplierOutput extends HydraItem { + /** + * Property id + * @readonly + * + * @description + * Stable supplier UUID. + * + * @access public + * + * @type {string} + */ + readonly id: string; + + /** + * Property organizationId + * @readonly + * + * @description + * Owning tenant. + * + * @access public + * + * @type {string} + */ + readonly organizationId: string; + + /** + * Property name + * @readonly + * + * @description + * Internal display name. + * + * @access public + * + * @type {string} + */ + readonly name: string; + + /** + * Property code + * @readonly + * + * @description + * Optional internal reference. + * + * @access public + * + * @type {string | null} + */ + readonly code?: string | null; + + /** + * Property email + * @readonly + * + * @description + * Optional general email. + * + * @access public + * + * @type {string | null} + */ + readonly email?: string | null; + + /** + * Property phone + * @readonly + * + * @description + * Optional general telephone. + * + * @access public + * + * @type {string | null} + */ + readonly phone?: string | null; + + /** + * Property contacts + * @readonly + * + * @description + * Internal contacts. + * + * @access public + * + * @type {readonly SupplierContact[]} + */ + readonly contacts: readonly SupplierContact[]; + + /** + * Property archivedAt + * @readonly + * + * @description + * Retained archival instant; archived suppliers cannot receive new orders. + * + * @access public + * + * @type {string | null} + */ + readonly archivedAt?: string | null; + + /** + * Property revision + * @readonly + * + * @description + * Server revision required by changes. + * + * @access public + * + * @type {number} + */ + readonly revision: number; + + /** + * Property createdAt + * @readonly + * + * @description + * Original creation instant. + * + * @access public + * + * @type {string} + */ + readonly createdAt: string; + + /** + * Property updatedAt + * @readonly + * + * @description + * Most recent server mutation instant. + * + * @access public + * + * @type {string} + */ + readonly updatedAt: string; + + /** + * Property replayed + * @readonly + * + * @description + * Whether the server recognized a prior mutation. + * + * @access public + * + * @type {boolean} + */ + readonly replayed: boolean; +} diff --git a/src/app/features/organization/features/procurement/procurement.routes.ts b/src/app/features/organization/features/procurement/procurement.routes.ts new file mode 100644 index 000000000..241001c2f --- /dev/null +++ b/src/app/features/organization/features/procurement/procurement.routes.ts @@ -0,0 +1,28 @@ +import type { Routes } from '@angular/router'; +import { unsavedChangesGuard } from '@shared/unsaved-changes'; +import { ProcurementStore } from './state'; + +/** + * Constant PROCUREMENT_ROUTES + * + * @description + * Organization guards procurement read; this owner preserves physical source context and drafts + * within its workspace. Secondary reads begin only after browser rendering. + * + * @since unreleased + * + * @type {Routes} + */ +export const PROCUREMENT_ROUTES: Routes = [ + { + path: '', + providers: [ProcurementStore], + canDeactivate: [unsavedChangesGuard], + loadComponent: () => + import('./ui/pages/procurement-page/procurement-page.component').then( + (module) => module.ProcurementPage, + ), + title: $localize`:@@route.procurement:Purchasing and receipts`, + data: { breadcrumb: false }, + }, +]; diff --git a/src/app/features/organization/features/procurement/state/index.ts b/src/app/features/organization/features/procurement/state/index.ts new file mode 100644 index 000000000..bcd2a25ce --- /dev/null +++ b/src/app/features/organization/features/procurement/state/index.ts @@ -0,0 +1,2 @@ +export { ProcurementStore, procurementStoreEvents } from './procurement'; +export type { ProcurementStoreType } from './procurement'; diff --git a/src/app/features/organization/features/procurement/state/procurement/events/events.ts b/src/app/features/organization/features/procurement/state/procurement/events/events.ts new file mode 100644 index 000000000..e7797c29b --- /dev/null +++ b/src/app/features/organization/features/procurement/state/procurement/events/events.ts @@ -0,0 +1,23 @@ +import { type } from '@ngrx/signals'; +import { eventGroup } from '@ngrx/signals/events'; +import type { + ProcurementCommand, + ProcurementMutationOutput, +} from '../models/procurement-command.type'; + +/** + * Constant procurementStoreEvents + * + * @description + * Confirmed write consequences; subscribers must match the original organization. + */ +export const procurementStoreEvents = eventGroup({ + source: 'Procurement Store', + events: { + saved: type<{ + organizationId: string; + command: ProcurementCommand; + result: ProcurementMutationOutput; + }>(), + }, +}); diff --git a/src/app/features/organization/features/procurement/state/procurement/index.ts b/src/app/features/organization/features/procurement/state/procurement/index.ts new file mode 100644 index 000000000..c482e7757 --- /dev/null +++ b/src/app/features/organization/features/procurement/state/procurement/index.ts @@ -0,0 +1,3 @@ +export { ProcurementStore } from './procurement.store'; +export type { ProcurementStoreType } from './procurement.store'; +export { procurementStoreEvents } from './events/events'; diff --git a/src/app/features/organization/features/procurement/state/procurement/models/procurement-command.type.ts b/src/app/features/organization/features/procurement/state/procurement/models/procurement-command.type.ts new file mode 100644 index 000000000..b18dc22cf --- /dev/null +++ b/src/app/features/organization/features/procurement/state/procurement/models/procurement-command.type.ts @@ -0,0 +1,94 @@ +import type { + ChangeSupplierInput, + CreateSupplierInput, + SupplierOutput, + ChangePurchaseOrderInput, + CreatePurchaseOrderInput, + PurchaseOrderOutput, + ProcurementReceiptOutput, + ReceivePurchaseOrderInput, + IndividualizeReceiptInput, + ReturnProcurementReceiptInput, + ProcurementReturnOutput, + ReconcileProcurementReturnInput, +} from '@features/organization/features/procurement/models'; + +/** + * Type ProcurementCommand + * + * @description + * One accepted write, retaining the original scope, resource revision and exact payload. + * + * @type {ProcurementCommand} + */ +export type ProcurementCommand = + | { + readonly kind: 'create_supplier'; + readonly organizationId: string; + readonly input: CreateSupplierInput; + } + | { + readonly kind: 'update_supplier'; + readonly organizationId: string; + readonly supplier: SupplierOutput; + readonly input: ChangeSupplierInput; + } + | { + readonly kind: 'archive_supplier'; + readonly organizationId: string; + readonly supplier: SupplierOutput; + } + | { + readonly kind: 'create_order'; + readonly organizationId: string; + readonly input: CreatePurchaseOrderInput; + } + | { + readonly kind: 'update_order'; + readonly organizationId: string; + readonly order: PurchaseOrderOutput; + readonly input: ChangePurchaseOrderInput; + } + | { + readonly kind: 'place_order' | 'cancel_remaining'; + readonly organizationId: string; + readonly order: PurchaseOrderOutput; + } + | { + readonly kind: 'receive'; + readonly organizationId: string; + readonly order: PurchaseOrderOutput; + readonly input: ReceivePurchaseOrderInput; + } + | { + readonly kind: 'individualize'; + readonly organizationId: string; + readonly receipt: ProcurementReceiptOutput; + readonly input: IndividualizeReceiptInput; + } + | { + readonly kind: 'reconcile'; + readonly organizationId: string; + readonly returned: ProcurementReturnOutput; + readonly input: ReconcileProcurementReturnInput; + } + | { + readonly kind: 'return'; + readonly organizationId: string; + readonly receipt: ProcurementReceiptOutput; + readonly input: ReturnProcurementReceiptInput; + }; + +/** + * Type ProcurementMutationOutput + * + * @description + * The resource projection returned by a confirmed procurement mutation. + * + * @type {ProcurementMutationOutput} + */ +export type ProcurementMutationOutput = + | SupplierOutput + | PurchaseOrderOutput + | ProcurementReceiptOutput + | ProcurementReturnOutput; diff --git a/src/app/features/organization/features/procurement/state/procurement/models/procurement-state.interface.ts b/src/app/features/organization/features/procurement/state/procurement/models/procurement-state.interface.ts new file mode 100644 index 000000000..cd3171b81 --- /dev/null +++ b/src/app/features/organization/features/procurement/state/procurement/models/procurement-state.interface.ts @@ -0,0 +1,251 @@ +import type { CallState } from '@core/request-state'; +import type { + PurchaseOrderOutput, + SupplierOutput, + ProcurementReceiptOutput, + ProcurementReturnOutput, +} from '@features/organization/features/procurement/models'; +import type { ProcurementCommand, ProcurementMutationOutput } from './procurement-command.type'; + +/** + * Interface ProcurementState + * @interface ProcurementState + * + * @description + * Scope and named request states for the procurement workspace. + */ +export interface ProcurementState { + /** + * Property organizationId + * @readonly + * + * @description + * Currently displayed organization. + * + * @access public + * + * @type {string | null} + */ + readonly organizationId: string | null; + + /** + * Property scopeVersion + * @readonly + * + * @description + * Monotonic scope generation rejecting responses from superseded visits. + * + * @access public + * + * @type {number} + */ + readonly scopeVersion: number; + + /** + * Property suppliersCallState + * @readonly + * + * @description + * Supplier directory request, retaining the previous page on refresh. + * + * @access public + * + * @type {CallState} + */ + readonly suppliersCallState: CallState; + + /** + * Property supplierCallState + * @readonly + * + * @description + * Selected supplier detail hydrates labels beyond the current active directory page. + * + * @access public + * + * @type {CallState} + */ + readonly supplierCallState: CallState; + + /** + * Property totalSuppliers + * @readonly + * + * @description + * Server count for supplier pagination. + * + * @access public + * + * @type {number} + */ + readonly totalSuppliers: number; + + /** + * Property ordersCallState + * @readonly + * + * @description + * Purchase collection request. + * + * @access public + * + * @type {CallState} + */ + readonly ordersCallState: CallState; + + /** + * Property totalOrders + * @readonly + * + * @description + * Server count for order pagination. + * + * @access public + * + * @type {number} + */ + readonly totalOrders: number; + + /** + * Property orderCallState + * @readonly + * + * @description + * Selected source order detail request. + * + * @access public + * + * @type {CallState} + */ + readonly orderCallState: CallState; + + /** + * Property receiptCallState + * @readonly + * + * @description + * Explicit source receipt revision review, independent from the paginated collection. + * + * @access public + * + * @type {CallState} + */ + readonly receiptCallState: CallState; + + /** + * Property returnCallState + * @readonly + * + * @description + * Explicit physical return revision review, independent from the paginated history. + * + * @access public + * + * @type {CallState} + */ + readonly returnCallState: CallState; + + /** + * Property receiptsCallState + * @readonly + * + * @description + * Physical receipt collection request for the selected order. + * + * @access public + * + * @type {CallState} + */ + readonly receiptsCallState: CallState; + + /** + * Property totalReceipts + * @readonly + * + * @description + * Server count for receipt pagination. + * + * @access public + * + * @type {number} + */ + readonly totalReceipts: number; + + /** + * Property returnsCallState + * @readonly + * + * @description + * Query state for the selected receipt's physical return history. + * + * @access public + * + * @type {CallState} + */ + readonly returnsCallState: CallState; + + /** + * Property totalReturns + * @readonly + * + * @description + * Server pagination count for retained returns. + * + * @access public + * + * @type {number} + */ + readonly totalReturns: number; + + /** + * Property returnReceiptId + * @readonly + * + * @description + * Source receipt whose returns are currently displayed. + * + * @access public + * + * @type {string | null} + */ + readonly returnReceiptId: string | null; + + /** + * Property receiptOrderId + * @readonly + * + * @description + * Source order whose physical history is being read. + * + * @access public + * + * @type {string | null} + */ + readonly receiptOrderId: string | null; + + /** + * Property commandCallState + * @readonly + * + * @description + * One serialized write request, separate from all query states. + * + * @access public + * + * @type {CallState} + */ + readonly commandCallState: CallState; + + /** + * Property command + * @readonly + * + * @description + * Unchanged command retained through an uncertain transport result. + * + * @access public + * + * @type {ProcurementCommand | null} + */ + readonly command: ProcurementCommand | null; +} diff --git a/src/app/features/organization/features/procurement/state/procurement/procurement.store.ts b/src/app/features/organization/features/procurement/state/procurement/procurement.store.ts new file mode 100644 index 000000000..84704c9fe --- /dev/null +++ b/src/app/features/organization/features/procurement/state/procurement/procurement.store.ts @@ -0,0 +1,545 @@ +import { computed, inject } from '@angular/core'; +import { tapResponse } from '@ngrx/operators'; +import { patchState, signalStore, type, withComputed, withMethods, withState } from '@ngrx/signals'; +import { removeAllEntities, setAllEntities, withEntities } from '@ngrx/signals/entities'; +import { Dispatcher } from '@ngrx/signals/events'; +import { rxMethod } from '@ngrx/signals/rxjs-interop'; +import { EMPTY, exhaustMap, pipe, switchMap, type Observable } from 'rxjs'; +import type { RequestOptions } from '@core/api/models'; +import { + errorCallState, + idleCallState, + pendingCallState, + successCallState, + toStoreError, +} from '@core/request-state'; +import { ProcurementService } from '@features/organization/features/procurement/data-access'; +import type { + SupplierOutput, + PurchaseOrderOutput, + ProcurementReceiptOutput, + ProcurementReturnOutput, +} from '@features/organization/features/procurement/models'; +import { procurementStoreEvents } from './events/events'; +import type { + ProcurementCommand, + ProcurementMutationOutput, +} from './models/procurement-command.type'; +import type { ProcurementState } from './models/procurement-state.interface'; +import { creationPayloadKey } from './utils/creation-payload-key/creation-payload-key.utils'; + +/** + * Constant ProcurementStore + * + * @description + * Server-paginated organization workspace with one serialized write pipeline. Reads cancel when + * superseded; uncertain writes retain their original revision and operation UUID for exact replay. + * + * @since unreleased + */ +export const ProcurementStore = signalStore( + withEntities({ entity: type(), collection: 'supplier' }), + withEntities({ entity: type(), collection: 'order' }), + withEntities({ entity: type(), collection: 'receipt' }), + withEntities({ entity: type(), collection: 'supplyReturn' }), + withState((): ProcurementState => ({ + organizationId: null, + scopeVersion: 0, + suppliersCallState: idleCallState(), + supplierCallState: idleCallState(), + totalSuppliers: 0, + ordersCallState: idleCallState(), + totalOrders: 0, + orderCallState: idleCallState(), + receiptCallState: idleCallState(), + returnCallState: idleCallState(), + receiptsCallState: idleCallState(), + totalReceipts: 0, + receiptOrderId: null, + returnsCallState: idleCallState(), + totalReturns: 0, + returnReceiptId: null, + commandCallState: idleCallState(), + command: null, + })), + withComputed((store) => ({ + /** + * @description + * An accepted write locks all conflicting workspace mutations. + */ + commandPending: computed(() => store.commandCallState().status === 'pending'), + /** + * @description + * A transport-uncertain physical command cannot be replaced by an edited payload. + */ + uncertainCommand: computed(() => { + const state = store.commandCallState(); + return ( + state.status === 'error' && + !!store.command() && + (state.error?.retryable === true || state.error?.code === 0) + ); + }), + /** + * @description + * Current server source order; quantities and lifecycle remain authoritative. + */ + selectedOrder: computed(() => store.orderCallState().data), + })), + withMethods((store, service = inject(ProcurementService), dispatcher = inject(Dispatcher)) => { + /** + * Constant request + * + * @description + * Sends one command through the owning transport without interpreting domain transitions. + */ + const request = (command: ProcurementCommand): Observable => { + const org = command.organizationId; + switch (command.kind) { + case 'create_supplier': + return service.createSupplier(org, command.input); + case 'update_supplier': + return service.updateSupplier(org, command.supplier, command.input); + case 'archive_supplier': + return service.archiveSupplier(org, command.supplier); + case 'create_order': + return service.createOrder(org, command.input); + case 'update_order': + return service.updateOrder(org, command.order, command.input); + case 'place_order': + return service.placeOrder(org, command.order); + case 'cancel_remaining': + return service.cancelRemaining(org, command.order); + case 'receive': + return service.receiveOrder(org, command.order, command.input); + case 'individualize': + return service.individualizeReceipt(org, command.receipt, command.input); + case 'return': + return service.returnReceipt(org, command.receipt, command.input); + case 'reconcile': + return service.reconcileReturn(org, command.returned, command.input); + } + }; + /** + * Constant prepareCreation + * + * @description + * Snapshots each new creation under a fresh operation UUID and retains failed identical drafts. + */ + const prepareCreation = (proposed: ProcurementCommand): ProcurementCommand => { + if (proposed.kind !== 'create_supplier' && proposed.kind !== 'create_order') return proposed; + const retained = store.command(); + if ( + store.commandCallState().status === 'error' && + retained?.kind === proposed.kind && + retained.organizationId === proposed.organizationId && + creationPayloadKey(retained.input) === creationPayloadKey(proposed.input) + ) + return retained; + const clientOperationId = globalThis.crypto.randomUUID(); + return proposed.kind === 'create_supplier' + ? { ...proposed, input: { ...structuredClone(proposed.input), clientOperationId } } + : { ...proposed, input: { ...structuredClone(proposed.input), clientOperationId } }; + }; + return { + /** + * Method setScope + * @method setScope + * + * @description + * Changes the display scope and discards only its old query projections. + * + * @access public + * @since unreleased + * + * @param {string} organizationId - Owning organization authority for the request. + * + * @returns {void} No return value; emits or updates the local draft only. + */ + setScope(organizationId: string): void { + if (store.organizationId() === organizationId) return; + patchState( + store, + removeAllEntities({ collection: 'supplier' }), + removeAllEntities({ collection: 'order' }), + removeAllEntities({ collection: 'receipt' }), + removeAllEntities({ collection: 'supplyReturn' }), + { + organizationId, + scopeVersion: store.scopeVersion() + 1, + suppliersCallState: idleCallState(), + supplierCallState: idleCallState(), + ordersCallState: idleCallState(), + orderCallState: idleCallState(), + receiptCallState: idleCallState(), + returnCallState: idleCallState(), + receiptsCallState: idleCallState(), + totalSuppliers: 0, + totalOrders: 0, + totalReceipts: 0, + receiptOrderId: null, + returnsCallState: idleCallState(), + totalReturns: 0, + returnReceiptId: null, + }, + ); + }, + /** + * @description + * Reads a supplier page; old scope results never populate the current organization. + */ + loadSuppliers: rxMethod<{ organizationId: string; options?: RequestOptions }>( + pipe( + switchMap(({ organizationId, options }) => { + if (store.organizationId() !== organizationId) return EMPTY; + const scopeVersion = store.scopeVersion(); + patchState(store, { suppliersCallState: pendingCallState() }); + return service.listSuppliers(organizationId, options).pipe( + tapResponse({ + next: (response) => { + if (store.scopeVersion() === scopeVersion) + patchState( + store, + setAllEntities([...response.member], { collection: 'supplier' }), + { + totalSuppliers: response.totalItems, + suppliersCallState: successCallState(null), + }, + ); + }, + error: (error: unknown) => { + if (store.scopeVersion() === scopeVersion) + patchState(store, { suppliersCallState: errorCallState(toStoreError(error)) }); + }, + }), + ); + }), + ), + ), + /** + * @description + * Reads the selected retained supplier label independently from current active choices. + */ + readSupplier: rxMethod<{ organizationId: string; supplierId: string }>( + pipe( + switchMap(({ organizationId, supplierId }) => { + if (store.organizationId() !== organizationId) return EMPTY; + const scopeVersion = store.scopeVersion(); + patchState(store, { supplierCallState: pendingCallState() }); + return service.readSupplier(organizationId, supplierId).pipe( + tapResponse({ + next: (supplier) => { + if (store.scopeVersion() === scopeVersion) + patchState(store, { supplierCallState: successCallState(supplier) }); + }, + error: (error: unknown) => { + if (store.scopeVersion() === scopeVersion) + patchState(store, { supplierCallState: errorCallState(toStoreError(error)) }); + }, + }), + ); + }), + ), + ), + /** + * @description + * Reads a server-filtered order page without deriving global totals in the browser. + */ + loadOrders: rxMethod<{ organizationId: string; options?: RequestOptions }>( + pipe( + switchMap(({ organizationId, options }) => { + if (store.organizationId() !== organizationId) return EMPTY; + const scopeVersion = store.scopeVersion(); + patchState(store, { ordersCallState: pendingCallState() }); + return service.listOrders(organizationId, options).pipe( + tapResponse({ + next: (response) => { + if (store.scopeVersion() === scopeVersion) + patchState( + store, + setAllEntities([...response.member], { collection: 'order' }), + { + totalOrders: response.totalItems, + ordersCallState: successCallState(null), + }, + ); + }, + error: (error: unknown) => { + if (store.scopeVersion() === scopeVersion) + patchState(store, { ordersCallState: errorCallState(toStoreError(error)) }); + }, + }), + ); + }), + ), + ), + /** + * @description + * Reads a selected source order independently from the current collection page. + */ + readOrder: rxMethod<{ organizationId: string; orderId: string }>( + pipe( + switchMap(({ organizationId, orderId }) => { + if (store.organizationId() !== organizationId) return EMPTY; + const scopeVersion = store.scopeVersion(); + patchState(store, { orderCallState: pendingCallState(store.selectedOrder()) }); + return service.readOrder(organizationId, orderId).pipe( + tapResponse({ + next: (order) => { + if (store.scopeVersion() === scopeVersion) + patchState(store, { orderCallState: successCallState(order) }); + }, + error: (error: unknown) => { + if (store.scopeVersion() === scopeVersion) + patchState(store, { + orderCallState: errorCallState(toStoreError(error), store.selectedOrder()), + }); + }, + }), + ); + }), + ), + ), + /** + * @description + * Reads retained physical receipts for one selected source order. + */ + loadReceipts: rxMethod<{ organizationId: string; orderId: string; options?: RequestOptions }>( + pipe( + switchMap(({ organizationId, orderId, options }) => { + if (store.organizationId() !== organizationId) return EMPTY; + const scopeVersion = store.scopeVersion(); + if (store.receiptOrderId() !== orderId) + patchState(store, removeAllEntities({ collection: 'receipt' })); + patchState(store, { receiptOrderId: orderId, receiptsCallState: pendingCallState() }); + return service.listReceipts(organizationId, orderId, options).pipe( + tapResponse({ + next: (response) => { + if (store.scopeVersion() === scopeVersion) + patchState( + store, + setAllEntities([...response.member], { collection: 'receipt' }), + { + totalReceipts: response.totalItems, + receiptsCallState: successCallState(null), + }, + ); + }, + error: (error: unknown) => { + if (store.scopeVersion() === scopeVersion) + patchState(store, { receiptsCallState: errorCallState(toStoreError(error)) }); + }, + }), + ); + }), + ), + ), + /** + * Property readReceipt + * @readonly + * + * @description + * Reads the retained physical receipt and its latest revision independently of paginated + * history. + */ + readReceipt: rxMethod<{ organizationId: string; receiptId: string }>( + pipe( + switchMap(({ organizationId, receiptId }) => { + if (store.organizationId() !== organizationId) return EMPTY; + const scopeVersion = store.scopeVersion(); + patchState(store, { receiptCallState: pendingCallState() }); + return service.readReceipt(organizationId, receiptId).pipe( + tapResponse({ + next: (receipt) => { + if (store.scopeVersion() === scopeVersion) + patchState(store, { receiptCallState: successCallState(receipt) }); + }, + error: (error: unknown) => { + if (store.scopeVersion() === scopeVersion) + patchState(store, { receiptCallState: errorCallState(toStoreError(error)) }); + }, + }), + ); + }), + ), + ), + /** + * Property readReturn + * @readonly + * + * @description + * Explicitly reads a physical return revision without losing its source when pagination + * changes. + */ + readReturn: rxMethod<{ organizationId: string; returnId: string }>( + pipe( + switchMap(({ organizationId, returnId }) => { + if (store.organizationId() !== organizationId) return EMPTY; + const scopeVersion = store.scopeVersion(); + patchState(store, { returnCallState: pendingCallState() }); + return service.readReturn(organizationId, returnId).pipe( + tapResponse({ + next: (returned) => { + if (store.scopeVersion() === scopeVersion) + patchState(store, { returnCallState: successCallState(returned) }); + }, + error: (error: unknown) => { + if (store.scopeVersion() === scopeVersion) + patchState(store, { returnCallState: errorCallState(toStoreError(error)) }); + }, + }), + ); + }), + ), + ), + /** + * Property loadReturns + * @readonly + * + * @description + * Reads paginated physical declarations and their separate inventory reconciliation status. + */ + loadReturns: rxMethod<{ + organizationId: string; + receiptId: string; + options?: RequestOptions; + }>( + pipe( + switchMap(({ organizationId, receiptId, options }) => { + if (store.organizationId() !== organizationId) return EMPTY; + const scopeVersion = store.scopeVersion(); + if (store.returnReceiptId() !== receiptId) + patchState(store, removeAllEntities({ collection: 'supplyReturn' })); + patchState(store, { returnReceiptId: receiptId, returnsCallState: pendingCallState() }); + return service.listReturns(organizationId, receiptId, options).pipe( + tapResponse({ + next: (response) => { + if (store.scopeVersion() === scopeVersion) + patchState( + store, + setAllEntities([...response.member], { collection: 'supplyReturn' }), + { + totalReturns: response.totalItems, + returnsCallState: successCallState(null), + }, + ); + }, + error: (error: unknown) => { + if (store.scopeVersion() === scopeVersion) + patchState(store, { returnsCallState: errorCallState(toStoreError(error)) }); + }, + }), + ); + }), + ), + ), + /** + * @description + * Uses the unchanged uncertain command for retry and never cancels a running write. + */ + execute: rxMethod( + pipe( + exhaustMap((proposed) => { + const state = store.commandCallState(); + const retained = store.command(); + const command = + retained && + state.status === 'error' && + (state.error?.retryable || state.error?.code === 0) + ? retained + : prepareCreation(proposed); + const scopeVersion = store.scopeVersion(); + patchState(store, { command, commandCallState: pendingCallState() }); + return request(command).pipe( + tapResponse({ + next: (result) => { + if (store.command() !== command) return; + const currentScope = + store.organizationId() === command.organizationId && + store.scopeVersion() === scopeVersion; + patchState(store, { + commandCallState: currentScope + ? successCallState(result) + : idleCallState(), + command: null, + }); + if (currentScope) { + dispatcher.dispatch( + procurementStoreEvents.saved({ + organizationId: command.organizationId, + command, + result, + }), + ); + } + }, + error: (error: unknown) => { + if (store.command() === command) + patchState(store, { commandCallState: errorCallState(toStoreError(error)) }); + }, + }), + ); + }), + ), + ), + /** + * Method clearCommand + * @method clearCommand + * + * @description + * Clears a confirmed rejection so its draft can be edited; uncertain physical facts remain + * locked. + * + * @access public + * @since unreleased + * + * @returns {void} No return value; emits or updates the local draft only. + */ + clearCommand(): void { + if (store.commandPending() || store.uncertainCommand()) return; + patchState(store, { command: null, commandCallState: idleCallState() }); + }, + /** + * Method clearOrder + * @method clearOrder + * + * @description + * Clears a previous source order before selecting a different purchase context. + * + * @access public + * @since unreleased + * + * @returns {void} No return value; emits or updates the local draft only. + */ + clearOrder(): void { + if (store.commandPending() || store.uncertainCommand()) return; + patchState( + store, + removeAllEntities({ collection: 'receipt' }), + removeAllEntities({ collection: 'supplyReturn' }), + { + orderCallState: idleCallState(), + receiptCallState: idleCallState(), + returnCallState: idleCallState(), + receiptsCallState: idleCallState(), + receiptOrderId: null, + totalReceipts: 0, + returnsCallState: idleCallState(), + returnReceiptId: null, + totalReturns: 0, + }, + ); + }, + }; + }), +); + +/** + * Type ProcurementStoreType + * + * @description + * Public instance shape consumed by the owning workspace and its tests. + * + * @type {ProcurementStoreType} + */ +export type ProcurementStoreType = InstanceType; diff --git a/src/app/features/organization/features/procurement/state/procurement/testing/procurement.store.spec.ts b/src/app/features/organization/features/procurement/state/procurement/testing/procurement.store.spec.ts new file mode 100644 index 000000000..2b17a5080 --- /dev/null +++ b/src/app/features/organization/features/procurement/state/procurement/testing/procurement.store.spec.ts @@ -0,0 +1,611 @@ +import { HttpErrorResponse } from '@angular/common/http'; +import { TestBed } from '@angular/core/testing'; +import { Dispatcher } from '@ngrx/signals/events'; +import { of, Subject, throwError } from 'rxjs'; +import type { HydraCollection } from '@core/api/models'; +import { ProcurementService } from '@features/organization/features/procurement/data-access'; +import type { + SupplierOutput, + PurchaseOrderOutput, + ProcurementReceiptOutput, + ProcurementReturnOutput, + ReceivePurchaseOrderInput, + CreateSupplierInput, + CreatePurchaseOrderInput, +} from '@features/organization/features/procurement/models'; +import type { ProcurementCommand } from '../models/procurement-command.type'; +import { ProcurementStore } from '../procurement.store'; +const supplier: SupplierOutput = { + '@id': '/suppliers/supplier', + '@type': 'Procurement', + id: 'supplier', + organizationId: 'org', + name: 'Fire supplies', + contacts: [], + revision: 3, + createdAt: '2026-10-05T10:00:00Z', + updatedAt: '2026-10-05T10:00:00Z', + replayed: false, +}; +const order: PurchaseOrderOutput = { + '@id': '/orders/order', + '@type': 'Procurement', + id: 'order', + organizationId: 'org', + supplierId: 'supplier', + name: 'Consumables and reserve equipment', + currency: 'EUR', + status: 'ordered', + lines: [], + financialVisible: false, + revision: 7, + createdAt: '2026-10-05T10:00:00Z', + updatedAt: '2026-10-05T10:00:00Z', + replayed: false, +}; +const receipt: ProcurementReceiptOutput = { + '@id': '/receipts/receipt', + '@type': 'Procurement', + id: 'receipt', + organizationId: 'org', + orderId: 'order', + lineId: 'line', + kind: 'part', + quantity: '0.250000', + warehouseId: 'warehouse', + currency: 'EUR', + receivedAt: '2026-10-05T10:00:00Z', + createdAt: '2026-10-05T10:00:00Z', + inventoryMovementId: 'movement', + equipmentIds: [], + returnedQuantity: '0.000000', + pendingReturnQuantity: '0.000000', + status: 'stock_received', + revision: 2, + financialVisible: false, + replayed: false, +}; + +const returned: ProcurementReturnOutput = { + '@id': '/returns/returned', + '@type': 'ProcurementReturn', + id: 'returned', + organizationId: 'org', + receiptId: 'receipt', + clientOperationId: 'd3b2f4b4-fb84-458b-8e65-e340d84ae97f', + quantity: '0.050000', + reason: 'Damaged packaging', + status: 'awaiting_reconciliation', + inventoryMovementId: null, + blockedReason: 'insufficient_stock', + createdAt: '2026-10-05T10:00:00Z', + reconciledAt: null, + revision: 4, + replayed: false, +}; + +describe('ProcurementStore', () => { + let store: InstanceType; + let service: { + listSuppliers: ReturnType; + listOrders: ReturnType; + readOrder: ReturnType; + readReceipt: ReturnType; + readReturn: ReturnType; + listReceipts: ReturnType; + receiveOrder: ReturnType; + individualizeReceipt: ReturnType; + returnReceipt: ReturnType; + listReturns: ReturnType; + reconcileReturn: ReturnType; + createSupplier: ReturnType; + createOrder: ReturnType; + }; + let dispatch: ReturnType; + const input: ReceivePurchaseOrderInput = { + lineId: 'line', + warehouseId: 'warehouse', + quantity: '0.250000', + receivedAt: '2026-10-05T12:00:00+02:00', + clientOperationId: '70ecb419-6dfb-4399-9d06-3d9e9540c053', + }; + beforeEach(() => { + service = { + listSuppliers: vi.fn().mockReturnValue(of({ member: [supplier], totalItems: 1 })), + listOrders: vi.fn().mockReturnValue(of({ member: [order], totalItems: 1 })), + readOrder: vi.fn().mockReturnValue(of(order)), + readReceipt: vi.fn().mockReturnValue(of(receipt)), + readReturn: vi.fn().mockReturnValue(of(returned)), + listReceipts: vi.fn().mockReturnValue(of({ member: [receipt], totalItems: 1 })), + receiveOrder: vi.fn().mockReturnValue(of(receipt)), + individualizeReceipt: vi.fn().mockReturnValue(of(receipt)), + returnReceipt: vi.fn().mockReturnValue(of(receipt)), + listReturns: vi.fn().mockReturnValue(of({ member: [returned], totalItems: 1 })), + reconcileReturn: vi.fn().mockReturnValue(of(returned)), + createSupplier: vi.fn().mockReturnValue(of(supplier)), + createOrder: vi.fn().mockReturnValue(of(order)), + }; + dispatch = vi.fn(); + TestBed.configureTestingModule({ + providers: [ + ProcurementStore, + { provide: ProcurementService, useValue: service }, + { provide: Dispatcher, useValue: { dispatch } }, + ], + }); + store = TestBed.inject(ProcurementStore); + store.setScope('org'); + }); + const creationCases = [ + { + name: 'supplier', + method: 'createSupplier', + makeCommand: (name: string): ProcurementCommand => ({ + kind: 'create_supplier', + organizationId: 'org', + input: { name, contacts: [{ name: 'Mary', role: 'Parts' }] }, + }), + result: supplier, + }, + { + name: 'purchase draft', + method: 'createOrder', + makeCommand: (name: string): ProcurementCommand => ({ + kind: 'create_order', + organizationId: 'org', + input: { + name, + supplierId: 'supplier', + lines: [{ kind: 'part', partId: 'part', quantity: '0.250000' }], + }, + }), + result: order, + }, + ] as const; + + it.each(creationCases)( + 'replays the original $name creation UUID and payload after a lost response', + ({ method, makeCommand }) => { + service[method].mockReturnValueOnce(throwError(() => new HttpErrorResponse({ status: 0 }))); + store.execute(makeCommand('Original draft')); + const firstInput = service[method].mock.calls[0][1] as + | CreateSupplierInput + | CreatePurchaseOrderInput; + expect(firstInput.clientOperationId).toMatch(/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/); + expect(store.uncertainCommand()).toBe(true); + store.clearCommand(); + store.execute(makeCommand('Edited draft')); + expect(service[method]).toHaveBeenLastCalledWith('org', firstInput); + expect(store.command()).toBeNull(); + expect(store.commandCallState().status).toBe('success'); + expect(dispatch).toHaveBeenCalledOnce(); + }, + ); + + it.each(creationCases)( + 'retains the $name creation UUID after an unchanged rejected draft and replaces it on an edit', + ({ method, makeCommand }) => { + service[method] + .mockReturnValueOnce(throwError(() => new HttpErrorResponse({ status: 400 }))) + .mockReturnValueOnce(throwError(() => new HttpErrorResponse({ status: 400 }))); + store.execute(makeCommand('Original draft')); + const firstInput = service[method].mock.calls[0][1] as + | CreateSupplierInput + | CreatePurchaseOrderInput; + store.execute(makeCommand('Original draft')); + expect(service[method]).toHaveBeenLastCalledWith('org', firstInput); + store.execute(makeCommand('Edited draft')); + const changedInput = service[method].mock.calls[2][1] as + | CreateSupplierInput + | CreatePurchaseOrderInput; + expect(changedInput.name).toBe('Edited draft'); + expect(changedInput.clientOperationId).not.toBe(firstInput.clientOperationId); + }, + ); + + it.each(creationCases)( + 'starts an identical new $name creation with a fresh UUID after success', + ({ method, makeCommand }) => { + store.execute(makeCommand('Original draft')); + const firstInput = service[method].mock.calls[0][1] as + | CreateSupplierInput + | CreatePurchaseOrderInput; + expect(store.command()).toBeNull(); + store.execute(makeCommand('Original draft')); + const nextInput = service[method].mock.calls[1][1] as + | CreateSupplierInput + | CreatePurchaseOrderInput; + expect(nextInput.clientOperationId).not.toBe(firstInput.clientOperationId); + expect(dispatch).toHaveBeenCalledTimes(2); + }, + ); + + it.each(creationCases)( + 'clears a definitively rejected $name creation before starting a new attempt', + ({ method, makeCommand }) => { + service[method].mockReturnValueOnce(throwError(() => new HttpErrorResponse({ status: 400 }))); + store.execute(makeCommand('Original draft')); + const firstInput = service[method].mock.calls[0][1] as + | CreateSupplierInput + | CreatePurchaseOrderInput; + store.clearCommand(); + expect(store.command()).toBeNull(); + expect(store.commandCallState().status).toBe('idle'); + store.execute(makeCommand('Original draft')); + const nextInput = service[method].mock.calls[1][1] as + | CreateSupplierInput + | CreatePurchaseOrderInput; + expect(nextInput.clientOperationId).not.toBe(firstInput.clientOperationId); + }, + ); + + it.each(creationCases)( + 'keeps only one accepted $name creation running and excludes its late result from another scope', + ({ method, makeCommand, result }) => { + const accepted = new Subject(); + service[method].mockReturnValue(accepted); + store.execute(makeCommand('Original draft')); + store.execute(makeCommand('Edited draft')); + expect(service[method]).toHaveBeenCalledOnce(); + expect(store.commandPending()).toBe(true); + store.setScope('another'); + accepted.next(result); + expect(dispatch).not.toHaveBeenCalled(); + expect(store.command()).toBeNull(); + expect(store.commandCallState().status).toBe('idle'); + expect(store.commandCallState().data).toBeNull(); + }, + ); + + it('snapshots supplier contacts before a lost response', () => { + const contacts = [{ name: 'Mary', role: 'Parts' }]; + service.createSupplier.mockReturnValueOnce( + throwError(() => new HttpErrorResponse({ status: 0 })), + ); + store.execute({ + kind: 'create_supplier', + organizationId: 'org', + input: { name: 'Fire supplies', contacts }, + }); + contacts[0].name = 'Changed after submission'; + store.execute({ + kind: 'create_supplier', + organizationId: 'org', + input: { contacts, name: 'Fire supplies' }, + }); + expect(service.createSupplier).toHaveBeenLastCalledWith('org', { + name: 'Fire supplies', + contacts: [{ name: 'Mary', role: 'Parts' }], + clientOperationId: expect.any(String), + }); + }); + + it('snapshots purchase lines and nested equipment identity before a lost response', () => { + const identityTemplate = { manufacturer: { name: 'Original' } }; + const lines = [ + { kind: 'equipment_to_individualize' as const, quantity: '2.000000', identityTemplate }, + ]; + service.createOrder.mockReturnValueOnce(throwError(() => new HttpErrorResponse({ status: 0 }))); + store.execute({ + kind: 'create_order', + organizationId: 'org', + input: { name: 'Reserve equipment', supplierId: 'supplier', lines }, + }); + lines[0].quantity = '3.000000'; + identityTemplate.manufacturer.name = 'Changed after submission'; + store.execute({ + kind: 'create_order', + organizationId: 'org', + input: { name: 'Reserve equipment', supplierId: 'supplier', lines }, + }); + expect(service.createOrder).toHaveBeenLastCalledWith('org', { + name: 'Reserve equipment', + supplierId: 'supplier', + lines: [ + { + kind: 'equipment_to_individualize', + quantity: '2.000000', + identityTemplate: { manufacturer: { name: 'Original' } }, + }, + ], + clientOperationId: expect.any(String), + }); + }); + + it('compares creation payloads independently from property order and a proposed operation UUID', () => { + service.createSupplier.mockReturnValueOnce( + throwError(() => new HttpErrorResponse({ status: 400 })), + ); + store.execute({ + kind: 'create_supplier', + organizationId: 'org', + input: { name: 'Fire supplies', contacts: [{ name: 'Mary', role: 'Parts' }] }, + }); + const firstInput = service.createSupplier.mock.calls[0][1] as CreateSupplierInput; + store.execute({ + kind: 'create_supplier', + organizationId: 'org', + input: { + contacts: [{ role: 'Parts', name: 'Mary' }], + name: 'Fire supplies', + clientOperationId: '5e8c271b-79e0-4a66-9396-b3e9324d79ec', + }, + }); + expect(service.createSupplier).toHaveBeenLastCalledWith('org', firstInput); + }); + + it('starts a fresh purchase creation operation when a rejected nested identity field changes', () => { + service.createOrder.mockReturnValueOnce( + throwError(() => new HttpErrorResponse({ status: 400 })), + ); + const draft: CreatePurchaseOrderInput = { + name: 'Reserve equipment', + supplierId: 'supplier', + lines: [ + { + kind: 'equipment_to_individualize', + quantity: '2.000000', + identityTemplate: { manufacturer: { name: 'Original' } }, + }, + ], + }; + store.execute({ kind: 'create_order', organizationId: 'org', input: draft }); + const firstInput = service.createOrder.mock.calls[0][1] as CreatePurchaseOrderInput; + store.execute({ + kind: 'create_order', + organizationId: 'org', + input: { + ...draft, + lines: draft.lines.map((line) => + Object.assign({}, line, { identityTemplate: { manufacturer: { name: 'Changed' } } }), + ), + }, + }); + const nextInput = service.createOrder.mock.calls[1][1] as CreatePurchaseOrderInput; + expect(nextInput.clientOperationId).not.toBe(firstInput.clientOperationId); + expect(nextInput.lines[0].identityTemplate).toEqual({ manufacturer: { name: 'Changed' } }); + }); + it('retains server pagination counts and cancels superseded supplier searches', () => { + const stale = new Subject>(); + service.listSuppliers.mockReturnValueOnce(stale); + store.loadSuppliers({ organizationId: 'org', options: { search: 'old' } }); + store.loadSuppliers({ organizationId: 'org', options: { search: 'fire' } }); + stale.next({ '@id': '/suppliers', '@type': 'Collection', member: [], totalItems: 90 }); + expect(store.supplierEntities()).toEqual([supplier]); + expect(store.totalSuppliers()).toBe(1); + }); + it('ignores an earlier visit response even after switching away and back to the same organization', () => { + const stale = new Subject(); + service.readOrder.mockReturnValueOnce(stale); + store.readOrder({ organizationId: 'org', orderId: 'order' }); + store.setScope('another'); + store.setScope('org'); + stale.next(order); + expect(store.selectedOrder()).toBeNull(); + }); + it('keeps one accepted physical command running despite a duplicate click', () => { + const accepted = new Subject(); + service.receiveOrder.mockReturnValue(accepted); + store.execute({ kind: 'receive', organizationId: 'org', order, input }); + store.execute({ + kind: 'receive', + organizationId: 'org', + order, + input: { ...input, quantity: '1.000000' }, + }); + expect(service.receiveOrder).toHaveBeenCalledTimes(1); + expect(store.commandPending()).toBe(true); + accepted.next(receipt); + expect(store.commandCallState().status).toBe('success'); + expect(dispatch).toHaveBeenCalledTimes(1); + }); + it('replays the exact uncertain declaration and original revision instead of a newly edited payload', () => { + service.receiveOrder.mockReturnValueOnce( + throwError(() => new HttpErrorResponse({ status: 0 })), + ); + store.execute({ kind: 'receive', organizationId: 'org', order, input }); + expect(store.uncertainCommand()).toBe(true); + store.clearCommand(); + expect(store.command()).not.toBeNull(); + store.execute({ + kind: 'receive', + organizationId: 'org', + order: { ...order, revision: 8 }, + input: { ...input, quantity: '1.000000', clientOperationId: 'new-operation' }, + }); + expect(service.receiveOrder).toHaveBeenLastCalledWith('org', order, input); + expect(store.command()).toBeNull(); + }); + it('does not inject a late accepted receipt into another tenant', () => { + const accepted = new Subject(); + service.receiveOrder.mockReturnValue(accepted); + store.execute({ kind: 'receive', organizationId: 'org', order, input }); + store.setScope('another'); + accepted.next(receipt); + expect(dispatch).not.toHaveBeenCalled(); + expect(store.receiptEntities()).toEqual([]); + expect(store.selectedOrder()).toBeNull(); + expect(store.commandCallState().status).toBe('idle'); + expect(store.commandCallState().data).toBeNull(); + }); + it('retains confirmed quota blocks as results and permits only an explicit new individualization attempt', () => { + const hardware = { + ...receipt, + kind: 'equipment_to_individualize' as const, + status: 'awaiting_individualization' as const, + blockedReason: 'quota_exceeded', + }; + service.individualizeReceipt.mockReturnValue(of(hardware)); + store.execute({ + kind: 'individualize', + organizationId: 'org', + receipt: hardware, + input: { clientOperationId: 'attempt-1' }, + }); + expect(store.commandCallState().data).toMatchObject({ + blockedReason: 'quota_exceeded', + equipmentIds: [], + }); + expect(store.uncertainCommand()).toBe(false); + expect(store.command()).toBeNull(); + store.execute({ + kind: 'individualize', + organizationId: 'org', + receipt: { ...hardware, revision: 3 }, + input: { clientOperationId: 'attempt-2' }, + }); + expect(service.individualizeReceipt).toHaveBeenLastCalledWith( + 'org', + { ...hardware, revision: 3 }, + { clientOperationId: 'attempt-2' }, + ); + }); + it('keeps definitive revision errors available for review while allowing the unchanged draft to be edited', () => { + service.receiveOrder.mockReturnValue( + throwError( + () => + new HttpErrorResponse({ + status: 412, + error: { title: 'Revision changed', detail: 'Review latest source order.' }, + }), + ), + ); + store.execute({ kind: 'receive', organizationId: 'org', order, input }); + expect(store.uncertainCommand()).toBe(false); + expect(store.commandCallState().error?.message).toContain('Review latest source order.'); + store.clearCommand(); + expect(store.commandCallState().status).toBe('idle'); + }); + + it('keeps pending physical returns visible and cancels an earlier receipt history read', () => { + const stale = new Subject>(); + service.listReturns.mockReturnValueOnce(stale); + store.loadReturns({ organizationId: 'org', receiptId: 'previous-receipt' }); + expect(store.returnsCallState().status).toBe('pending'); + store.loadReturns({ organizationId: 'org', receiptId: 'receipt', options: { page: 2 } }); + stale.next({ '@id': '/returns', '@type': 'Collection', member: [], totalItems: 90 }); + expect(store.supplyReturnEntities()).toEqual([returned]); + expect(store.totalReturns()).toBe(1); + expect(store.returnReceiptId()).toBe('receipt'); + expect(store.returnsCallState().status).toBe('success'); + expect(service.listReturns).toHaveBeenLastCalledWith('org', 'receipt', { page: 2 }); + }); + + it('ignores a return history response after leaving and re-entering the same tenant', () => { + const stale = new Subject>(); + service.listReturns.mockReturnValueOnce(stale); + store.loadReturns({ organizationId: 'org', receiptId: 'receipt' }); + store.setScope('another'); + store.setScope('org'); + stale.next({ '@id': '/returns', '@type': 'Collection', member: [returned], totalItems: 1 }); + expect(store.supplyReturnEntities()).toEqual([]); + expect(store.returnReceiptId()).toBeNull(); + expect(store.returnsCallState().status).toBe('idle'); + }); + + it('retains a confirmed return shortage as a physical fact rather than a transport uncertainty', () => { + const physicalReceipt = { + ...receipt, + returnedQuantity: '0.050000', + pendingReturnQuantity: '0.050000', + revision: 3, + }; + service.returnReceipt.mockReturnValue(of(physicalReceipt)); + const returnInput = { + quantity: returned.quantity, + reason: returned.reason, + clientOperationId: returned.clientOperationId, + }; + store.execute({ kind: 'return', organizationId: 'org', receipt, input: returnInput }); + expect(store.commandCallState().status).toBe('success'); + expect(store.commandCallState().data).toEqual(physicalReceipt); + expect(store.command()).toBeNull(); + expect(store.uncertainCommand()).toBe(false); + expect(dispatch).toHaveBeenCalledOnce(); + }); + + it('replays an uncertain reconciliation with the original revision and identical attempt UUID', () => { + const reconciliation = { clientOperationId: '722df8a7-b9a6-4e19-a10a-6c52f2e7a01d' }; + service.reconcileReturn.mockReturnValueOnce( + throwError(() => new HttpErrorResponse({ status: 0 })), + ); + store.execute({ kind: 'reconcile', organizationId: 'org', returned, input: reconciliation }); + expect(store.uncertainCommand()).toBe(true); + expect(store.commandCallState().status).toBe('error'); + store.clearCommand(); + expect(store.command()).not.toBeNull(); + store.execute({ + kind: 'reconcile', + organizationId: 'org', + returned: { ...returned, revision: 5 }, + input: { clientOperationId: 'new-attempt' }, + }); + expect(service.reconcileReturn).toHaveBeenLastCalledWith('org', returned, reconciliation); + expect(store.commandCallState().status).toBe('success'); + expect(store.commandCallState().data).toEqual(returned); + expect(store.command()).toBeNull(); + }); + + it('keeps one reconciliation running and excludes late completion from another tenant', () => { + const accepted = new Subject(); + service.reconcileReturn.mockReturnValue(accepted); + const reconciliation = { clientOperationId: '722df8a7-b9a6-4e19-a10a-6c52f2e7a01d' }; + store.execute({ kind: 'reconcile', organizationId: 'org', returned, input: reconciliation }); + store.execute({ kind: 'reconcile', organizationId: 'org', returned, input: reconciliation }); + expect(service.reconcileReturn).toHaveBeenCalledOnce(); + expect(store.commandPending()).toBe(true); + store.setScope('another'); + accepted.next({ ...returned, status: 'confirmed', revision: 5 }); + expect(dispatch).not.toHaveBeenCalled(); + expect(store.supplyReturnEntities()).toEqual([]); + expect(store.returnReceiptId()).toBeNull(); + expect(store.commandCallState().status).toBe('idle'); + expect(store.commandCallState().data).toBeNull(); + }); + + it('refreshes one receipt revision independently from the paginated history and cancels superseded reads', () => { + const stale = new Subject(); + const latest = { ...receipt, revision: 3, returnedQuantity: '0.050000' }; + service.readReceipt.mockReturnValueOnce(stale).mockReturnValueOnce(of(latest)); + store.readReceipt({ organizationId: 'org', receiptId: 'previous-receipt' }); + expect(store.receiptCallState().status).toBe('pending'); + store.readReceipt({ organizationId: 'org', receiptId: 'receipt' }); + stale.next({ ...receipt, id: 'previous-receipt' }); + expect(store.receiptCallState().status).toBe('success'); + expect(store.receiptCallState().data).toEqual(latest); + expect(store.receiptEntities()).toEqual([]); + expect(service.readReceipt).toHaveBeenLastCalledWith('org', 'receipt'); + }); + + it('refreshes the retained return revision without deriving it from the visible page', () => { + const latest = { ...returned, revision: 5, status: 'confirmed' as const }; + service.readReturn.mockReturnValue(of(latest)); + store.readReturn({ organizationId: 'org', returnId: 'returned' }); + expect(store.returnCallState().status).toBe('success'); + expect(store.returnCallState().data).toEqual(latest); + expect(store.supplyReturnEntities()).toEqual([]); + expect(latest.clientOperationId).toBe(returned.clientOperationId); + expect(latest.quantity).toBe(returned.quantity); + expect(latest.reason).toBe(returned.reason); + store.clearOrder(); + expect(store.receiptCallState().status).toBe('idle'); + expect(store.returnCallState().status).toBe('idle'); + }); + + it('discards targeted receipt and return responses after a superseded organization visit', () => { + const staleReceipt = new Subject(); + const staleReturn = new Subject(); + service.readReceipt.mockReturnValue(staleReceipt); + service.readReturn.mockReturnValue(staleReturn); + store.readReceipt({ organizationId: 'org', receiptId: 'receipt' }); + store.readReturn({ organizationId: 'org', returnId: 'returned' }); + store.setScope('another'); + store.setScope('org'); + staleReceipt.next(receipt); + staleReturn.next(returned); + expect(store.receiptCallState().status).toBe('idle'); + expect(store.receiptCallState().data).toBeNull(); + expect(store.returnCallState().status).toBe('idle'); + expect(store.returnCallState().data).toBeNull(); + }); +}); diff --git a/src/app/features/organization/features/procurement/state/procurement/utils/creation-payload-key/creation-payload-key.utils.ts b/src/app/features/organization/features/procurement/state/procurement/utils/creation-payload-key/creation-payload-key.utils.ts new file mode 100644 index 000000000..e27c8f3f7 --- /dev/null +++ b/src/app/features/organization/features/procurement/state/procurement/utils/creation-payload-key/creation-payload-key.utils.ts @@ -0,0 +1,33 @@ +import type { + CreatePurchaseOrderInput, + CreateSupplierInput, +} from '@features/organization/features/procurement/models'; + +/** + * Function creationPayloadKey + * + * @description + * Compares the submitted creation fields independently from the operation UUID and object key + * order. Arrays retain their order because draft lines and contacts are part of the submitted + * command. + * + * @access public + * + * @param {CreateSupplierInput | CreatePurchaseOrderInput} input - Exact creation payload to + * compare. + * + * @returns {string} Stable JSON representation of the business fields. + */ +export function creationPayloadKey(input: CreateSupplierInput | CreatePurchaseOrderInput): string { + return JSON.stringify( + { ...input, clientOperationId: undefined }, + (_key, value: unknown): unknown => { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return value; + return Object.fromEntries( + Object.entries(value).toSorted(([left], [right]) => + left < right ? -1 : left > right ? 1 : 0, + ), + ); + }, + ); +} diff --git a/src/app/features/organization/features/procurement/ui/forms/procurement-receipt-form/index.ts b/src/app/features/organization/features/procurement/ui/forms/procurement-receipt-form/index.ts new file mode 100644 index 000000000..de077829f --- /dev/null +++ b/src/app/features/organization/features/procurement/ui/forms/procurement-receipt-form/index.ts @@ -0,0 +1 @@ +export { ProcurementReceiptForm } from './procurement-receipt-form.component'; diff --git a/src/app/features/organization/features/procurement/ui/forms/procurement-receipt-form/models/procurement-receipt-draft.interface.ts b/src/app/features/organization/features/procurement/ui/forms/procurement-receipt-form/models/procurement-receipt-draft.interface.ts new file mode 100644 index 000000000..c53464b3a --- /dev/null +++ b/src/app/features/organization/features/procurement/ui/forms/procurement-receipt-form/models/procurement-receipt-draft.interface.ts @@ -0,0 +1,55 @@ +/** + * Interface ProcurementReceiptDraft + * @interface ProcurementReceiptDraft + * + * @description + * Editable delivery values before the user declares one physical event. + */ +export interface ProcurementReceiptDraft { + /** + * Property quantity + * + * @description + * Exact quantity text. + * + * @access public + * + * @type {string} + */ + quantity: string; + + /** + * Property warehouseId + * + * @description + * Server-authorized receiving warehouse for stock articles. + * + * @access public + * + * @type {string} + */ + warehouseId: string; + + /** + * Property localTime + * + * @description + * Device-local wall time from the native calendar control. + * + * @access public + * + * @type {string} + */ + localTime: string; + /** + * Property offsetChoice + * + * @description + * Explicit device UTC offset when a repeated local hour identifies two physical instants. + * + * @access public + * + * @type {string} + */ + offsetChoice: string; +} diff --git a/src/app/features/organization/features/procurement/ui/forms/procurement-receipt-form/procurement-receipt-form.component.html b/src/app/features/organization/features/procurement/ui/forms/procurement-receipt-form/procurement-receipt-form.component.html new file mode 100644 index 000000000..8ae2da000 --- /dev/null +++ b/src/app/features/organization/features/procurement/ui/forms/procurement-receipt-form/procurement-receipt-form.component.html @@ -0,0 +1,134 @@ +
+

+ Still awaiting delivery: {{ line().remainingQuantity }}. This receipt can contain up to + {{ limit() }}. +

+
+ + @if (receiptForm.quantity().touched()) { + @for (error of receiptForm.quantity().errors(); track error) { + {{ error.message }} + } + } + + @if (line().kind === 'part') { + + @if (receiptForm.warehouseId().touched()) { + @for (error of receiptForm.warehouseId().errors(); track error) { + {{ error.message }} + } + } + + } @else { +

+ Materials remain in this receipt until you explicitly create their reserve equipment + identities. No quantitative stock is added. +

+ } + +

+ Use the local date and time of your device. The recorded instant retains its timezone. +

+ @if (receiptForm.localTime().touched()) { + @for (error of receiptForm.localTime().errors(); track error) { + {{ error.message }} + } + } +
+ @if (possibleTimes().length > 1) { + + + + + + @for (date of possibleTimes(); track date.toMillis()) { + UTC {{ date.toFormat('ZZ') }} + } + + +

+ The same local time occurred twice because the clocks changed. Select the offset shown on + the physical receipt record. +

+ @if (receiptForm.offsetChoice().touched()) { + @for (error of receiptForm.offsetChoice().errors(); track error) { + {{ error.message }} + } + } +
+ } +
+

+ Use a decimal point and at most six decimal places. Quantities and amounts remain exact. +

+ @if (error(); as message) { +
+

Receipt could not be confirmed

+

{{ message }}

+
+ } +
+ +
+
diff --git a/src/app/features/organization/features/procurement/ui/forms/procurement-receipt-form/procurement-receipt-form.component.ts b/src/app/features/organization/features/procurement/ui/forms/procurement-receipt-form/procurement-receipt-form.component.ts new file mode 100644 index 000000000..39ff6d94c --- /dev/null +++ b/src/app/features/organization/features/procurement/ui/forms/procurement-receipt-form/procurement-receipt-form.component.ts @@ -0,0 +1,390 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + input, + output, + signal, + untracked, + type InputSignal, + type OutputEmitterRef, + type Signal, + type WritableSignal, +} from '@angular/core'; +import { + disabled, + form, + FormField, + required, + validate, + type FieldTree, +} from '@angular/forms/signals'; +import { DateTime } from 'luxon'; +import { InventoryWarehousePicker } from '@features/organization/features/inventory/ui/components/inventory-warehouse-picker'; +import type { + PurchaseOrderLineOutput, + ReceivePurchaseOrderInput, +} from '@features/organization/features/procurement/models'; +import { + canonicalExactDecimal, + exactDecimalUnits, +} from '@features/organization/features/procurement/utils'; +import { isProcurementQuantity } from '@features/organization/features/procurement/validators'; +import { HlmAlertImports } from '@shared/ui/alert'; +import { HlmButton } from '@shared/ui/button'; +import { HlmFieldImports } from '@shared/ui/field'; +import { HlmInput } from '@shared/ui/input'; +import { HlmSelectImports } from '@shared/ui/select'; +import { HlmSpinner } from '@shared/ui/spinner'; +import type { ProcurementReceiptDraft } from './models/procurement-receipt-draft.interface'; + +/** + * Class ProcurementReceiptForm + * @class ProcurementReceiptForm + * + * @description + * Native physical delivery form; no stock or reserve units are created until server confirmation. + */ +@Component({ + selector: 'app-procurement-receipt-form', + templateUrl: './procurement-receipt-form.component.html', + imports: [ + FormField, + HlmInput, + HlmButton, + HlmSpinner, + InventoryWarehousePicker, + ...HlmAlertImports, + ...HlmFieldImports, + ...HlmSelectImports, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ProcurementReceiptForm { + //#region Properties + /** + * Property organizationId + * @readonly + * + * @description + * Tenant passed to the warehouse picker. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly organizationId: InputSignal = input.required(); + + /** + * Property line + * @readonly + * + * @description + * Source line with the current server remaining quantity. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly line: InputSignal = + input.required(); + + /** + * Property pending + * @readonly + * + * @description + * Accepted or transport-uncertain writes lock editing. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly pending: InputSignal = input(false); + + /** + * Property error + * @readonly + * + * @description + * Normalized recoverable server failure. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly error: InputSignal = input(null); + + /** + * Property submitted + * @readonly + * + * @description + * Validated physical declaration; its operation UUID is retained by the store. + * + * @access public + * @since unreleased + * + * @type {OutputEmitterRef} + */ + public readonly submitted: OutputEmitterRef = + output(); + + /** + * Property cancelled + * @readonly + * + * @description + * Controlled dismissal intent. + * + * @access public + * @since unreleased + * + * @type {OutputEmitterRef} + */ + public readonly cancelled: OutputEmitterRef = output(); + + /** + * Property dirtyChanged + * @readonly + * + * @description + * Actual native dirtiness for dismissal protection. + * + * @access public + * @since unreleased + * + * @type {OutputEmitterRef} + */ + public readonly dirtyChanged: OutputEmitterRef = output(); + + /** + * Property draft + * @readonly + * + * @description + * Text input never converts quantity through a floating-point number. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal} + */ + protected readonly draft: WritableSignal = signal({ + quantity: '', + warehouseId: '', + localTime: '', + offsetChoice: '', + }); + /** + * Property possibleTimes + * @readonly + * + * @description + * Rejects normalized nonexistent local hours and retains both possible instants of a repeated + * hour. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly possibleTimes: Signal = computed(() => { + const value: string = this.draft().localTime; + const date: DateTime = DateTime.fromISO(value); + return date.isValid && date.toFormat("yyyy-MM-dd'T'HH:mm") === value + ? date.getPossibleOffsets() + : []; + }); + /** + * Property resolvedTime + * @readonly + * + * @description + * A repeated hour remains unresolved until the reader explicitly selects its UTC offset. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly resolvedTime: Signal = computed(() => { + const times: readonly DateTime[] = this.possibleTimes(); + return times.length === 1 + ? (times[0] ?? null) + : (times.find((date) => date.toFormat('ZZ') === this.draft().offsetChoice) ?? null); + }); + + /** + * Property limit + * @readonly + * + * @description + * A single equipment receipt is bounded to 100 units and the server remaining quantity. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly limit: Signal = computed(() => + this.line().kind === 'equipment_to_individualize' && + (exactDecimalUnits(this.line().remainingQuantity) ?? 0n) > 100000000n + ? '100.000000' + : this.line().remainingQuantity, + ); + + /** + * Property receiptForm + * @readonly + * + * @description + * Native field tree and local bounds; concurrency remains authoritative on the server. + * + * @access protected + * @since unreleased + * + * @type {FieldTree} + */ + protected readonly receiptForm: FieldTree = form(this.draft, (path) => { + disabled(path, () => this.pending()); + validate(path.quantity, ({ value }) => + isProcurementQuantity(value(), this.line().kind, this.limit()) + ? null + : { + kind: 'quantity', + message: $localize`:@@procurement.receipt.quantityInvalid:Enter a positive quantity within the remaining delivery. Equipment needs whole units, up to 100 per receipt.`, + }, + ); + required(path.warehouseId, { + when: () => this.line().kind === 'part', + message: $localize`:@@procurement.receipt.warehouseRequired:Choose the receiving warehouse.`, + }); + required(path.localTime, { + message: $localize`:@@procurement.receipt.timeRequired:Enter the physical receipt date and time.`, + }); + validate(path.localTime, () => { + const date: DateTime | null = + this.resolvedTime() ?? + this.possibleTimes().find((candidate) => candidate <= DateTime.now()) ?? + null; + return date !== null && date <= DateTime.now() + ? null + : { + kind: 'date', + message: $localize`:@@procurement.receipt.timeInvalid:Choose a valid receipt date and time that is not in the future.`, + }; + }); + validate(path.offsetChoice, () => + this.possibleTimes().length <= 1 || this.resolvedTime() !== null + ? null + : { + kind: 'offset', + message: $localize`:@@procurement.receipt.offsetRequired:This local hour occurs twice. Choose the UTC offset of the actual receipt.`, + }, + ); + }); + + /** + * Property seededLineId + * + * @description + * Source identity already seeded; revision refresh never discards typed physical facts. + * + * @access private + * @since unreleased + * + * @type {string | null} + */ + private seededLineId: string | null = null; + /** + * Property offsetTime + * + * @description + * A changed local time requires a new explicit repeated-hour decision. + * + * @access private + * @since unreleased + * + * @type {string | null} + */ + private offsetTime: string | null = null; + //#endregion + + //#region Constructor + /** + * Constructor + * @constructor + * + * @description + * Defaults the physical time only once for the selected source line. + * + * @access public + * @since unreleased + */ + public constructor() { + effect(() => { + const id = this.line().id; + if (id === this.seededLineId) return; + this.seededLineId = id; + untracked(() => + this.receiptForm().reset({ + quantity: '', + warehouseId: '', + localTime: DateTime.local().toFormat("yyyy-MM-dd'T'HH:mm"), + offsetChoice: '', + }), + ); + }); + effect(() => { + const localTime: string = this.draft().localTime; + if (localTime === this.offsetTime) return; + this.offsetTime = localTime; + untracked(() => this.draft.update((draft) => ({ ...draft, offsetChoice: '' }))); + }); + effect(() => { + const dirty = this.receiptForm().dirty(); + untracked(() => this.dirtyChanged.emit(dirty)); + }); + } + //#endregion + + //#region Methods + /** + * Method submit + * @method submit + * + * @description + * Emits one explicit-offset physical declaration and a fresh UUID for its first attempt. + * + * @access protected + * @since unreleased + * + * @param {Event} event - Native form submission event. + * + * @returns {void} No return value; emits or updates the local draft only. + */ + protected submit(event: Event): void { + event.preventDefault(); + this.receiptForm().markAsTouched(); + if (this.pending() || this.receiptForm().invalid()) return; + const draft: ProcurementReceiptDraft = this.draft(); + const receivedAt: string | null = + this.resolvedTime()?.toUTC().toISO({ suppressMilliseconds: true }) ?? null; + const quantity: string | null = canonicalExactDecimal(draft.quantity); + if (!receivedAt || !quantity) return; + this.submitted.emit({ + lineId: this.line().id, + quantity, + receivedAt, + clientOperationId: globalThis.crypto.randomUUID(), + ...(this.line().kind === 'part' ? { warehouseId: draft.warehouseId } : {}), + }); + } + //#endregion +} diff --git a/src/app/features/organization/features/procurement/ui/forms/procurement-receipt-form/testing/procurement-receipt-form.component.spec.ts b/src/app/features/organization/features/procurement/ui/forms/procurement-receipt-form/testing/procurement-receipt-form.component.spec.ts new file mode 100644 index 000000000..23790a0f2 --- /dev/null +++ b/src/app/features/organization/features/procurement/ui/forms/procurement-receipt-form/testing/procurement-receipt-form.component.spec.ts @@ -0,0 +1,241 @@ +import { PLATFORM_ID, type WritableSignal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { DateTime, Settings } from 'luxon'; +import { InventoryService } from '@features/organization/features/inventory/data-access'; +import type { PurchaseOrderLineOutput } from '@features/organization/features/procurement/models'; +import type { ProcurementReceiptDraft } from '../models/procurement-receipt-draft.interface'; +import { ProcurementReceiptForm } from '../procurement-receipt-form.component'; + +describe('ProcurementReceiptForm', () => { + const organizationId = 'org'; + const line: PurchaseOrderLineOutput = { + id: 'line', + kind: 'part', + partId: 'part', + identityTemplate: {}, + quantity: '0.500000', + receivedQuantity: '0.200000', + returnedQuantity: '0.000000', + remainingQuantity: '0.300000', + }; + const zone = Settings.defaultZone; + const now = Settings.now; + const declaration: ProcurementReceiptDraft = { + quantity: '0.25', + warehouseId: 'warehouse', + localTime: '2026-10-05T12:00', + offsetChoice: '', + }; + + beforeAll(() => { + globalThis.ResizeObserver ??= class { + observe(): void {} + unobserve(): void {} + disconnect(): void {} + } as unknown as typeof ResizeObserver; + }); + beforeEach(() => { + Settings.defaultZone = 'Europe/Paris'; + Settings.now = () => Date.UTC(2026, 9, 6, 12); + }); + afterEach(() => { + Settings.defaultZone = zone; + Settings.now = now; + }); + + async function setup(source: PurchaseOrderLineOutput = line) { + TestBed.configureTestingModule({ + imports: [ProcurementReceiptForm], + providers: [ + { provide: PLATFORM_ID, useValue: 'server' }, + { provide: InventoryService, useValue: {} }, + ], + }); + const fixture = TestBed.createComponent(ProcurementReceiptForm); + fixture.componentRef.setInput('organizationId', organizationId); + fixture.componentRef.setInput('line', source); + const emitted = vi.fn(); + fixture.componentInstance.submitted.subscribe(emitted); + await fixture.whenStable(); + const form = fixture.componentInstance as unknown as { + draft: WritableSignal; + submit: (event: Event) => void; + }; + return { fixture, form, emitted }; + } + + it('emits a partial decimal receipt for the selected warehouse and an explicit-offset instant', async () => { + const { fixture, form, emitted } = await setup(); + form.draft.set(declaration); + await fixture.whenStable(); + form.submit(new Event('submit')); + expect(emitted).toHaveBeenCalledExactlyOnceWith({ + lineId: 'line', + warehouseId: 'warehouse', + quantity: '0.250000', + receivedAt: '2026-10-05T10:00:00Z', + clientOperationId: expect.any(String), + }); + const result = emitted.mock.calls[0][0]; + expect(result.clientOperationId).toMatch(/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/); + expect(DateTime.fromISO(result.receivedAt).toMillis()).toBe( + DateTime.fromISO('2026-10-05T12:00:00+02:00').toMillis(), + ); + }); + + it.each(['0', '0.300001', '0.1234567', '1e-1'])( + 'rejects a receipt quantity outside the exact source limit: %s', + async (quantity) => { + const { fixture, form, emitted } = await setup(); + form.draft.set({ ...declaration, quantity }); + await fixture.whenStable(); + form.submit(new Event('submit')); + expect(emitted).not.toHaveBeenCalled(); + }, + ); + + it('requires a receiving warehouse for a stock article', async () => { + const { fixture, form, emitted } = await setup(); + form.draft.set({ ...declaration, warehouseId: '' }); + await fixture.whenStable(); + form.submit(new Event('submit')); + expect(emitted).not.toHaveBeenCalled(); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toContain('Choose the receiving warehouse.'); + }); + + it.each(['101', '1.5'])( + 'rejects equipment receipt quantity %s while preserving physical input', + async (quantity) => { + const { fixture, form, emitted } = await setup({ + ...line, + kind: 'equipment_to_individualize', + partId: null, + typeCode: 'EXTINGUISHER', + quantity: '200.000000', + remainingQuantity: '200.000000', + }); + form.draft.set({ ...declaration, quantity, warehouseId: '' }); + await fixture.whenStable(); + form.submit(new Event('submit')); + expect(emitted).not.toHaveBeenCalled(); + expect(form.draft().quantity).toBe(quantity); + }, + ); + + it('accepts at most one hundred individual units and omits warehouse stock fields', async () => { + const { fixture, form, emitted } = await setup({ + ...line, + kind: 'equipment_to_individualize', + partId: null, + typeCode: 'EXTINGUISHER', + quantity: '200.000000', + remainingQuantity: '200.000000', + }); + form.draft.set({ ...declaration, quantity: '100', warehouseId: 'stale-stock-selection' }); + await fixture.whenStable(); + form.submit(new Event('submit')); + expect(emitted.mock.calls[0][0]).toEqual({ + lineId: 'line', + quantity: '100.000000', + receivedAt: '2026-10-05T10:00:00Z', + clientOperationId: expect.any(String), + }); + expect(fixture.nativeElement.querySelector('app-inventory-warehouse-picker')).toBeNull(); + }); + + it.each(['invalid', '', '2026-10-07T12:00'])( + 'rejects an invalid or future physical time %s', + async (localTime) => { + const { fixture, form, emitted } = await setup(); + form.draft.set({ ...declaration, localTime }); + await fixture.whenStable(); + form.submit(new Event('submit')); + expect(emitted).not.toHaveBeenCalled(); + }, + ); + + it('rejects a nonexistent device-local hour without normalizing or discarding the physical draft', async () => { + const { fixture, form, emitted } = await setup(); + const gap = { ...declaration, localTime: '2026-03-29T02:30' }; + form.draft.set(gap); + await fixture.whenStable(); + form.submit(new Event('submit')); + expect(emitted).not.toHaveBeenCalled(); + expect(form.draft()).toEqual(gap); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('hlm-field-error')).not.toBeNull(); + }); + + it('requires an explicit UTC offset for a repeated device-local hour', async () => { + const { fixture, form, emitted } = await setup(); + const fold = { ...declaration, localTime: '2025-10-26T02:30' }; + form.draft.set(fold); + await fixture.whenStable(); + form.submit(new Event('submit')); + expect(emitted).not.toHaveBeenCalled(); + expect(form.draft()).toEqual(fold); + fixture.detectChanges(); + expect(fixture.nativeElement.textContent).toContain('UTC offset'); + }); + + it.each([ + { offsetChoice: '+02:00', receivedAt: '2025-10-26T00:30:00Z' }, + { offsetChoice: '+01:00', receivedAt: '2025-10-26T01:30:00Z' }, + ])( + 'records the explicitly chosen $offsetChoice physical instant during a repeated hour', + async ({ offsetChoice, receivedAt }) => { + const { fixture, form, emitted } = await setup(); + form.draft.set({ ...declaration, localTime: '2025-10-26T02:30' }); + await fixture.whenStable(); + form.draft.update((draft) => ({ ...draft, offsetChoice })); + await fixture.whenStable(); + form.submit(new Event('submit')); + expect(emitted).toHaveBeenCalledExactlyOnceWith({ + lineId: 'line', + warehouseId: 'warehouse', + quantity: '0.250000', + receivedAt, + clientOperationId: expect.any(String), + }); + }, + ); + + it('requires a new repeated-hour decision whenever the device-local date changes', async () => { + const { fixture, form, emitted } = await setup(); + form.draft.set({ ...declaration, localTime: '2025-10-26T02:30' }); + await fixture.whenStable(); + form.draft.update((draft) => ({ ...draft, offsetChoice: '+02:00' })); + await fixture.whenStable(); + form.draft.update((draft) => ({ ...draft, localTime: '2025-10-26T02:45' })); + await fixture.whenStable(); + expect(form.draft().offsetChoice).toBe(''); + form.submit(new Event('submit')); + expect(emitted).not.toHaveBeenCalled(); + expect(form.draft().localTime).toBe('2025-10-26T02:45'); + }); + + it('retains the physical draft through source revision, failure and pending transitions', async () => { + const { fixture, form, emitted } = await setup(); + form.draft.set(declaration); + fixture.componentRef.setInput('line', { ...line, remainingQuantity: '0.280000' }); + fixture.componentRef.setInput('error', 'Review the remaining delivery.'); + fixture.componentRef.setInput('pending', true); + await fixture.whenStable(); + form.submit(new Event('submit')); + expect(emitted).not.toHaveBeenCalled(); + expect(form.draft()).toEqual(declaration); + expect(fixture.nativeElement.querySelector('[role="alert"]').textContent).toContain( + 'Review the remaining delivery.', + ); + fixture.componentRef.setInput('pending', false); + fixture.componentRef.setInput('line', { ...line, id: 'another-line' }); + await fixture.whenStable(); + expect(form.draft()).toEqual({ + quantity: '', + warehouseId: '', + localTime: '2026-10-06T14:00', + offsetChoice: '', + }); + }); +}); diff --git a/src/app/features/organization/features/procurement/ui/forms/procurement-return-form/index.ts b/src/app/features/organization/features/procurement/ui/forms/procurement-return-form/index.ts new file mode 100644 index 000000000..51e0eeb7a --- /dev/null +++ b/src/app/features/organization/features/procurement/ui/forms/procurement-return-form/index.ts @@ -0,0 +1 @@ +export { ProcurementReturnForm } from './procurement-return-form.component'; diff --git a/src/app/features/organization/features/procurement/ui/forms/procurement-return-form/models/procurement-return-draft.interface.ts b/src/app/features/organization/features/procurement/ui/forms/procurement-return-form/models/procurement-return-draft.interface.ts new file mode 100644 index 000000000..aaa03e894 --- /dev/null +++ b/src/app/features/organization/features/procurement/ui/forms/procurement-return-form/models/procurement-return-draft.interface.ts @@ -0,0 +1,32 @@ +/** + * Interface ProcurementReturnDraft + * @interface ProcurementReturnDraft + * + * @description + * Editable exact return quantity and motivated physical explanation. + */ +export interface ProcurementReturnDraft { + /** + * Property quantity + * + * @description + * Exact positive quantity text. + * + * @access public + * + * @type {string} + */ + quantity: string; + + /** + * Property reason + * + * @description + * Reason retained with the physical source receipt. + * + * @access public + * + * @type {string} + */ + reason: string; +} diff --git a/src/app/features/organization/features/procurement/ui/forms/procurement-return-form/procurement-return-form.component.html b/src/app/features/organization/features/procurement/ui/forms/procurement-return-form/procurement-return-form.component.html new file mode 100644 index 000000000..2a3233676 --- /dev/null +++ b/src/app/features/organization/features/procurement/ui/forms/procurement-return-form/procurement-return-form.component.html @@ -0,0 +1,72 @@ +
+

+ Received units not already returned: {{ limit() }}. +

+
+ + @if (returnForm.quantity().touched()) { + @for (error of returnForm.quantity().errors(); track error) { + {{ error.message }} + } + } + + + @if (returnForm.reason().touched()) { + @for (error of returnForm.reason().errors(); track error) { + {{ error.message }} + } + } + +
+

+ The return remains linked to its original receipt. If stock needs reconciliation, the physical + declaration is retained until the movement can be confirmed. +

+ @if (error(); as message) { +
+

Return could not be confirmed

+

{{ message }}

+
+ } +
+ +
+
diff --git a/src/app/features/organization/features/procurement/ui/forms/procurement-return-form/procurement-return-form.component.ts b/src/app/features/organization/features/procurement/ui/forms/procurement-return-form/procurement-return-form.component.ts new file mode 100644 index 000000000..3445d0973 --- /dev/null +++ b/src/app/features/organization/features/procurement/ui/forms/procurement-return-form/procurement-return-form.component.ts @@ -0,0 +1,287 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + input, + output, + signal, + untracked, + type InputSignal, + type OutputEmitterRef, + type Signal, + type WritableSignal, +} from '@angular/core'; +import { + disabled, + form, + FormField, + maxLength, + required, + validate, + type FieldTree, +} from '@angular/forms/signals'; +import type { + ProcurementReceiptOutput, + ReturnProcurementReceiptInput, +} from '@features/organization/features/procurement/models'; +import { + canonicalExactDecimal, + exactDecimalDifference, +} from '@features/organization/features/procurement/utils'; +import { isProcurementQuantity } from '@features/organization/features/procurement/validators'; +import { HlmAlertImports } from '@shared/ui/alert'; +import { HlmButton } from '@shared/ui/button'; +import { HlmFieldImports } from '@shared/ui/field'; +import { HlmInput } from '@shared/ui/input'; +import { HlmSpinner } from '@shared/ui/spinner'; +import { HlmTextarea } from '@shared/ui/textarea'; +import type { ProcurementReturnDraft } from './models/procurement-return-draft.interface'; + +/** + * Class ProcurementReturnForm + * @class ProcurementReturnForm + * + * @description + * Motivated physical return form linked to the original receipt and its exact quantities. + */ +@Component({ + selector: 'app-procurement-return-form', + templateUrl: './procurement-return-form.component.html', + imports: [ + FormField, + HlmInput, + HlmTextarea, + HlmButton, + HlmSpinner, + ...HlmAlertImports, + ...HlmFieldImports, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ProcurementReturnForm { + //#region Properties + /** + * Property receipt + * @readonly + * + * @description + * Retained source receipt, never replaced with a newly invented physical record. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly receipt: InputSignal = + input.required(); + + /** + * Property pending + * @readonly + * + * @description + * Accepted or transport-uncertain writes lock the exact declaration. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly pending: InputSignal = input(false); + + /** + * Property error + * @readonly + * + * @description + * Normalized server rejection. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly error: InputSignal = input(null); + + /** + * Property submitted + * @readonly + * + * @description + * Explicit motivated return with stable UUID assigned on its first submission. + * + * @access public + * @since unreleased + * + * @type {OutputEmitterRef} + */ + public readonly submitted: OutputEmitterRef = + output(); + + /** + * Property cancelled + * @readonly + * + * @description + * Controlled dismissal intent. + * + * @access public + * @since unreleased + * + * @type {OutputEmitterRef} + */ + public readonly cancelled: OutputEmitterRef = output(); + + /** + * Property dirtyChanged + * @readonly + * + * @description + * Actual native dirtiness for dismissal protection. + * + * @access public + * @since unreleased + * + * @type {OutputEmitterRef} + */ + public readonly dirtyChanged: OutputEmitterRef = output(); + + /** + * Property draft + * @readonly + * + * @description + * No quantity is parsed through a floating-point value. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal} + */ + protected readonly draft: WritableSignal = signal({ + quantity: '', + reason: '', + }); + + /** + * Property limit + * @readonly + * + * @description + * Local bound only; concurrent and insufficient stock checks remain server-owned. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly limit: Signal = computed(() => + exactDecimalDifference(this.receipt().quantity, this.receipt().returnedQuantity), + ); + + /** + * Property returnForm + * @readonly + * + * @description + * Native reason and quantity schema. + * + * @access protected + * @since unreleased + * + * @type {FieldTree} + */ + protected readonly returnForm: FieldTree = form(this.draft, (path) => { + disabled(path, () => this.pending()); + validate(path.quantity, ({ value }) => + isProcurementQuantity(value(), this.receipt().kind, this.limit()) + ? null + : { + kind: 'quantity', + message: $localize`:@@procurement.return.quantityInvalid:Enter a positive quantity within the received units not already returned.`, + }, + ); + required(path.reason, { + message: $localize`:@@procurement.return.reasonRequired:Explain why the goods were returned.`, + }); + validate(path.reason, ({ value }) => + value().trim() + ? null + : { + kind: 'reason', + message: $localize`:@@procurement.return.reasonRequired:Explain why the goods were returned.`, + }, + ); + maxLength(path.reason, 2000); + }); + + /** + * Property seededReceiptId + * + * @description + * Seeding follows receipt identity, preserving drafts during revision refresh. + * + * @access private + * @since unreleased + * + * @type {string | null} + */ + private seededReceiptId: string | null = null; + //#endregion + + //#region Constructor + /** + * Constructor + * @constructor + * + * @description + * Resets only on a new physical source and reports native dirtiness. + * + * @access public + * @since unreleased + */ + public constructor() { + effect(() => { + const id = this.receipt().id; + if (id === this.seededReceiptId) return; + this.seededReceiptId = id; + untracked(() => this.returnForm().reset({ quantity: '', reason: '' })); + }); + effect(() => { + const dirty = this.returnForm().dirty(); + untracked(() => this.dirtyChanged.emit(dirty)); + }); + } + //#endregion + + //#region Methods + /** + * Method submit + * @method submit + * + * @description + * Emits one exact physical return without creating an implicit stock correction. + * + * @access protected + * @since unreleased + * + * @param {Event} event - Native form submission event. + * + * @returns {void} No return value; emits or updates the local draft only. + */ + protected submit(event: Event): void { + event.preventDefault(); + this.returnForm().markAsTouched(); + if (this.pending() || this.returnForm().invalid()) return; + const draft: ProcurementReturnDraft = this.draft(); + const quantity: string | null = canonicalExactDecimal(draft.quantity); + if (!quantity) return; + this.submitted.emit({ + quantity, + reason: draft.reason.trim(), + clientOperationId: globalThis.crypto.randomUUID(), + }); + } + //#endregion +} diff --git a/src/app/features/organization/features/procurement/ui/forms/procurement-return-form/testing/procurement-return-form.component.spec.ts b/src/app/features/organization/features/procurement/ui/forms/procurement-return-form/testing/procurement-return-form.component.spec.ts new file mode 100644 index 000000000..95364a6d3 --- /dev/null +++ b/src/app/features/organization/features/procurement/ui/forms/procurement-return-form/testing/procurement-return-form.component.spec.ts @@ -0,0 +1,123 @@ +import type { WritableSignal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import type { ProcurementReceiptOutput } from '@features/organization/features/procurement/models'; +import type { ProcurementReturnDraft } from '../models/procurement-return-draft.interface'; +import { ProcurementReturnForm } from '../procurement-return-form.component'; + +describe('ProcurementReturnForm', () => { + const receipt: ProcurementReceiptOutput = { + '@id': '/receipts/receipt', + '@type': 'ProcurementReceipt', + id: 'receipt', + organizationId: 'org', + orderId: 'order', + lineId: 'line', + kind: 'part', + quantity: '0.300000', + returnedQuantity: '0.100000', + pendingReturnQuantity: '0.100000', + warehouseId: 'warehouse', + currency: 'EUR', + receivedAt: '2026-10-05T10:00:00Z', + createdAt: '2026-10-05T10:00:00Z', + equipmentIds: [], + status: 'stock_received', + revision: 3, + financialVisible: false, + replayed: false, + }; + const declaration: ProcurementReturnDraft = { quantity: '0.20', reason: ' Damaged packaging ' }; + + async function setup(source?: ProcurementReceiptOutput) { + TestBed.configureTestingModule({ imports: [ProcurementReturnForm] }); + const fixture = TestBed.createComponent(ProcurementReturnForm); + fixture.componentRef.setInput('receipt', source ?? receipt); + const emitted = vi.fn(); + fixture.componentInstance.submitted.subscribe(emitted); + await fixture.whenStable(); + const form = fixture.componentInstance as unknown as { + draft: WritableSignal; + submit: (event: Event) => void; + }; + return { fixture, form, emitted }; + } + + it('emits one motivated exact return without counting pending reconciliation twice', async () => { + const { fixture, form, emitted } = await setup(); + form.draft.set(declaration); + await fixture.whenStable(); + form.submit(new Event('submit')); + expect(emitted).toHaveBeenCalledExactlyOnceWith({ + quantity: '0.200000', + reason: 'Damaged packaging', + clientOperationId: expect.any(String), + }); + expect(emitted.mock.calls[0][0].clientOperationId).toMatch( + /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/, + ); + }); + + it.each(['0', '0.200001', '0.1234567', '-0.1', '1e-1'])( + 'rejects return quantity %s outside its source receipt', + async (quantity) => { + const { fixture, form, emitted } = await setup(); + form.draft.set({ ...declaration, quantity }); + await fixture.whenStable(); + form.submit(new Event('submit')); + expect(emitted).not.toHaveBeenCalled(); + expect(form.draft().quantity).toBe(quantity); + }, + ); + + it.each(['', ' ', 'a'.repeat(2001)])( + 'requires a reason of at most two thousand characters', + async (reason) => { + const { fixture, form, emitted } = await setup(); + form.draft.set({ ...declaration, reason }); + await fixture.whenStable(); + form.submit(new Event('submit')); + expect(emitted).not.toHaveBeenCalled(); + }, + ); + + it('rejects fractional equipment units while accepting a whole return', async () => { + const { fixture, form, emitted } = await setup({ + ...receipt, + kind: 'equipment_to_individualize', + quantity: '5.000000', + returnedQuantity: '1.000000', + pendingReturnQuantity: '0.000000', + }); + form.draft.set({ ...declaration, quantity: '1.5' }); + await fixture.whenStable(); + form.submit(new Event('submit')); + expect(emitted).not.toHaveBeenCalled(); + form.draft.set({ ...declaration, quantity: '4' }); + await fixture.whenStable(); + form.submit(new Event('submit')); + expect(emitted).toHaveBeenCalledWith({ + quantity: '4.000000', + reason: 'Damaged packaging', + clientOperationId: expect.any(String), + }); + }); + + it('preserves the reason across failures and receipt revisions and resets on a new source', async () => { + const { fixture, form, emitted } = await setup(); + form.draft.set(declaration); + fixture.componentRef.setInput('receipt', { ...receipt, revision: 4 }); + fixture.componentRef.setInput('error', 'Stock needs reconciliation.'); + fixture.componentRef.setInput('pending', true); + await fixture.whenStable(); + form.submit(new Event('submit')); + expect(emitted).not.toHaveBeenCalled(); + expect(form.draft()).toEqual(declaration); + expect(fixture.nativeElement.querySelector('[role="alert"]').textContent).toContain( + 'Stock needs reconciliation.', + ); + fixture.componentRef.setInput('receipt', { ...receipt, id: 'another-receipt' }); + fixture.componentRef.setInput('pending', false); + await fixture.whenStable(); + expect(form.draft()).toEqual({ quantity: '', reason: '' }); + }); +}); diff --git a/src/app/features/organization/features/procurement/ui/forms/purchase-order-form/index.ts b/src/app/features/organization/features/procurement/ui/forms/purchase-order-form/index.ts new file mode 100644 index 000000000..4941f7da3 --- /dev/null +++ b/src/app/features/organization/features/procurement/ui/forms/purchase-order-form/index.ts @@ -0,0 +1 @@ +export { PurchaseOrderForm } from './purchase-order-form.component'; diff --git a/src/app/features/organization/features/procurement/ui/forms/purchase-order-form/models/purchase-order-draft.interface.ts b/src/app/features/organization/features/procurement/ui/forms/purchase-order-form/models/purchase-order-draft.interface.ts new file mode 100644 index 000000000..8fa6f4535 --- /dev/null +++ b/src/app/features/organization/features/procurement/ui/forms/purchase-order-form/models/purchase-order-draft.interface.ts @@ -0,0 +1,173 @@ +import type { ProcurementLineKind } from '@features/organization/features/procurement/models'; +/** + * Interface PurchaseOrderLineDraft + * @interface PurchaseOrderLineDraft + * + * @description + * Editable line fields remain text, including all exact quantity and cost values. + */ +export interface PurchaseOrderLineDraft { + /** + * Property id + * + * @description + * Stable existing or local line UUID. + * + * @access public + * + * @type {string} + */ + id: string; + + /** + * Property kind + * + * @description + * Stock article or individual material. + * + * @access public + * + * @type {ProcurementLineKind} + */ + kind: ProcurementLineKind; + + /** + * Property partId + * + * @description + * Selected stock article. + * + * @access public + * + * @type {string} + */ + partId: string; + + /** + * Property typeCode + * + * @description + * Selected authorized catalogue code. + * + * @access public + * + * @type {string} + */ + typeCode: string; + + /** + * Property quantity + * + * @description + * User-entered exact quantity. + * + * @access public + * + * @type {string} + */ + quantity: string; + + /** + * Property unitCost + * + * @description + * Authorized optional unit cost text. + * + * @access public + * + * @type {string} + */ + unitCost: string; + + /** + * Property name + * + * @description + * Declarative equipment name. + * + * @access public + * + * @type {string} + */ + name: string; + + /** + * Property brand + * + * @description + * Declarative manufacturer. + * + * @access public + * + * @type {string} + */ + brand: string; + + /** + * Property model + * + * @description + * Declarative model. + * + * @access public + * + * @type {string} + */ + model: string; + + /** + * Property identityTemplate + * + * @description + * Retained unknown declarative fields, preserved by editing. + * + * @access public + * + * @type {Record} + */ + identityTemplate: Record; +} +/** + * Interface PurchaseOrderDraft + * @interface PurchaseOrderDraft + * + * @description + * Editable purchase draft; currency is owned by the organization and server. + */ +export interface PurchaseOrderDraft { + /** + * Property name + * + * @description + * Internal order name. + * + * @access public + * + * @type {string} + */ + name: string; + + /** + * Property supplierId + * + * @description + * Internal active supplier. + * + * @access public + * + * @type {string} + */ + supplierId: string; + + /** + * Property lines + * + * @description + * Bounded independently identified lines. + * + * @access public + * + * @type {PurchaseOrderLineDraft[]} + */ + lines: PurchaseOrderLineDraft[]; +} diff --git a/src/app/features/organization/features/procurement/ui/forms/purchase-order-form/purchase-order-form.component.html b/src/app/features/organization/features/procurement/ui/forms/purchase-order-form/purchase-order-form.component.html new file mode 100644 index 000000000..856e11af9 --- /dev/null +++ b/src/app/features/organization/features/procurement/ui/forms/purchase-order-form/purchase-order-form.component.html @@ -0,0 +1,309 @@ +
+
+ Purchase draft +
+ + @if (orderForm.name().touched()) { + @for (error of orderForm.name().errors(); track error) { + {{ error.message }} + } + } + + + + + No active matching supplier loaded. +
+ @for (supplier of activeSuppliers(); track supplier.id) { + {{ + supplierLabelOf(supplier.id) + }} + } +
+
+ @if (orderForm.supplierId().touched()) { + @for (error of orderForm.supplierId().errors(); track error) { + {{ error.message }} + } + } + @if (supplierState().status === 'pending') { +

Loading suppliers…

+ } + @if (supplierState().error; as error) { + {{ error.message }} + } + @if (supplierPageCount() > 1) { +
+ +
+ } +
+
+
+ @for (line of draft().lines; track line.id; let index = $index) { +
+ Order line {{ index + 1 }} +
+ + + @for (kind of kinds; track kind.value) { + {{ kind.label }} + } + + + @if (line.kind === 'part') { + + @if (orderForm.lines[index].partId().touched()) { + @for (error of orderForm.lines[index].partId().errors(); track error) { + {{ error.message }} + } + } + + } @else { + + @for (type of selectableTypes(line.typeCode); track type.value) { + {{ type.label }} + } + + @if (orderForm.lines[index].typeCode().touched()) { + @for (error of orderForm.lines[index].typeCode().errors(); track error) { + {{ error.message }} + } + } + + +
+ +
+

+ Received materials await explicit creation as reserve equipment. They are not + quantitative stock. +

+ } + + @if (orderForm.lines[index].quantity().touched()) { + @for (error of orderForm.lines[index].quantity().errors(); track error) { + {{ error.message }} + } + } + + @if (canEditCosts()) { + +

+ Organization currency. Leave empty when unknown. No commercial tax or billing is + calculated here. +

+ @if (orderForm.lines[index].unitCost().touched()) { + @for (error of orderForm.lines[index].unitCost().errors(); track error) { + {{ error.message }} + } + } +
+ } + +
+
+ } + @for (error of orderForm.lines().errors(); track error) { + {{ error.message }} + } + +

+ Use a decimal point and at most six decimal places. Quantities and amounts remain exact. +

+ @if (error(); as message) { +
+

Purchase draft could not be saved

+

{{ message }}

+
+ } +
+ +
+
diff --git a/src/app/features/organization/features/procurement/ui/forms/purchase-order-form/purchase-order-form.component.ts b/src/app/features/organization/features/procurement/ui/forms/purchase-order-form/purchase-order-form.component.ts new file mode 100644 index 000000000..27fae80c1 --- /dev/null +++ b/src/app/features/organization/features/procurement/ui/forms/purchase-order-form/purchase-order-form.component.ts @@ -0,0 +1,746 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + input, + output, + signal, + untracked, + type InputSignal, + type OutputEmitterRef, + type Signal, + type WritableSignal, +} from '@angular/core'; +import { + applyEach, + disabled, + form, + FormField, + maxLength, + required, + validate, + type FieldTree, +} from '@angular/forms/signals'; +import type { CallState } from '@core/request-state'; +import { idleCallState } from '@core/request-state'; +import type { EquipmentTypeOption } from '@features/organization/features/equipments'; +import { InventoryPartPicker } from '@features/organization/features/inventory/ui/components/inventory-part-picker'; +import type { + ChangePurchaseOrderInput, + ProcurementLineKind, + PurchaseOrderLineInput, + PurchaseOrderOutput, + SupplierOutput, +} from '@features/organization/features/procurement/models'; +import { canonicalExactDecimal } from '@features/organization/features/procurement/utils'; +import { isProcurementQuantity } from '@features/organization/features/procurement/validators'; +import { HlmAlertImports } from '@shared/ui/alert'; +import { HlmButton } from '@shared/ui/button'; +import { HlmComboboxImports } from '@shared/ui/combobox'; +import { HlmFieldImports } from '@shared/ui/field'; +import { HlmInput } from '@shared/ui/input'; +import { HlmSelectImports } from '@shared/ui/select'; +import { HlmSpinner } from '@shared/ui/spinner'; +import type { + PurchaseOrderDraft, + PurchaseOrderLineDraft, +} from './models/purchase-order-draft.interface'; + +/** + * Function newLine + * + * @description + * Creates one local line identity before any server mutation. + * + * @access public + * @since unreleased + * + * @returns {PurchaseOrderLineDraft} New local draft line with a stable UUID. + */ +function newLine(): PurchaseOrderLineDraft { + return { + id: globalThis.crypto.randomUUID(), + kind: 'part', + partId: '', + typeCode: '', + quantity: '1', + unitCost: '', + name: '', + brand: '', + model: '', + identityTemplate: {}, + }; +} + +/** + * Class PurchaseOrderForm + * @class PurchaseOrderForm + * + * @description + * Native purchase draft form with exact strings, explicit resource kinds and recoverable edits. + */ +@Component({ + selector: 'app-purchase-order-form', + templateUrl: './purchase-order-form.component.html', + imports: [ + FormField, + HlmInput, + HlmButton, + HlmSpinner, + InventoryPartPicker, + ...HlmAlertImports, + ...HlmFieldImports, + ...HlmComboboxImports, + ...HlmSelectImports, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class PurchaseOrderForm { + //#region Properties + /** + * Property organizationId + * @readonly + * + * @description + * Owning organization passed to authorized stock pickers. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly organizationId: InputSignal = input.required(); + + /** + * Property order + * @readonly + * + * @description + * Selected draft, or null to create a new one. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly order: InputSignal = + input(null); + + /** + * Property suppliers + * @readonly + * + * @description + * Current server-authorized supplier page. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly suppliers: InputSignal = input< + readonly SupplierOutput[] + >([]); + + /** + * Property selectedSupplier + * @readonly + * + * @description + * Retained selected supplier, including its archived label. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly selectedSupplier: InputSignal = + input(null); + + /** + * Property supplierState + * @readonly + * + * @description + * Supplier picker read status. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly supplierState: InputSignal = input(idleCallState()); + + /** + * Property supplierPage + * @readonly + * + * @description + * Current server supplier page. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly supplierPage: InputSignal = input(1); + + /** + * Property supplierPageCount + * @readonly + * + * @description + * Server-derived number of supplier pages. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly supplierPageCount: InputSignal = input(1); + + /** + * Property typeOptions + * @readonly + * + * @description + * Organization catalogue, including historical type labels. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly typeOptions: InputSignal = input< + readonly EquipmentTypeOption[] + >([]); + + /** + * Property canEditCosts + * @readonly + * + * @description + * Independent dedicated financial read and manage capability. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly canEditCosts: InputSignal = input(false); + + /** + * Property pending + * @readonly + * + * @description + * Accepted or unresolved mutation locks all editing. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly pending: InputSignal = input(false); + + /** + * Property error + * @readonly + * + * @description + * Recoverable normalized server error. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly error: InputSignal = input(null); + + /** + * Property submitted + * @readonly + * + * @description + * Validated draft intent; this form makes no API calls. + * + * @access public + * @since unreleased + * + * @type {OutputEmitterRef} + */ + public readonly submitted: OutputEmitterRef = + output(); + + /** + * Property cancelled + * @readonly + * + * @description + * Requested controlled dismissal. + * + * @access public + * @since unreleased + * + * @type {OutputEmitterRef} + */ + public readonly cancelled: OutputEmitterRef = output(); + + /** + * Property dirtyChanged + * @readonly + * + * @description + * Actual native dirtiness for editor and route protection. + * + * @access public + * @since unreleased + * + * @type {OutputEmitterRef} + */ + public readonly dirtyChanged: OutputEmitterRef = output(); + + /** + * Property supplierSearched + * @readonly + * + * @description + * Server search intent; local filtering does not hide later pages. + * + * @access public + * @since unreleased + * + * @type {OutputEmitterRef} + */ + public readonly supplierSearched: OutputEmitterRef = output(); + + /** + * Property supplierPageChanged + * @readonly + * + * @description + * Server pagination intent. + * + * @access public + * @since unreleased + * + * @type {OutputEmitterRef} + */ + public readonly supplierPageChanged: OutputEmitterRef = output(); + /** + * Property equipmentTypesRequested + * @readonly + * + * @description + * The hidden equipment catalogue is needed only after an individual-material line is selected. + * + * @access public + * @since unreleased + * + * @type {OutputEmitterRef} + */ + public readonly equipmentTypesRequested: OutputEmitterRef = output(); + + /** + * Property draft + * @readonly + * + * @description + * Editable text fields and declarative identities. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal} + */ + protected readonly draft: WritableSignal = signal({ + name: '', + supplierId: '', + lines: [], + }); + + /** + * Property orderForm + * @readonly + * + * @description + * Native schema validates conditional references and exact quantity boundaries. + * + * @access protected + * @since unreleased + * + * @type {FieldTree} + */ + protected readonly orderForm: FieldTree = form(this.draft, (path) => { + disabled(path, () => this.pending()); + required(path.name, { + message: $localize`:@@procurement.order.nameRequired:Enter the order name.`, + }); + validate(path.name, ({ value }) => + value().trim() + ? null + : { + kind: 'required', + message: $localize`:@@procurement.order.nameRequired:Enter the order name.`, + }, + ); + maxLength(path.name, 160); + required(path.supplierId, { + message: $localize`:@@procurement.order.supplierRequired:Choose an internal supplier.`, + }); + validate(path.lines, ({ value }) => + value().length > 0 && value().length <= 100 + ? null + : { + kind: 'lines', + message: $localize`:@@procurement.order.linesRequired:Add between 1 and 100 lines.`, + }, + ); + applyEach(path.lines, (line) => { + required(line.partId, { + when: ({ valueOf }) => valueOf(line.kind) === 'part', + message: $localize`:@@procurement.order.partRequired:Choose a stock article.`, + }); + required(line.typeCode, { + when: ({ valueOf }) => valueOf(line.kind) === 'equipment_to_individualize', + message: $localize`:@@procurement.order.typeRequired:Choose an equipment type.`, + }); + validate(line.quantity, ({ value, valueOf }) => + isProcurementQuantity(value(), valueOf(line.kind)) + ? null + : { + kind: 'quantity', + message: $localize`:@@procurement.quantity.invalid:Enter a positive quantity up to 100000 with at most six decimals. Equipment needs whole units.`, + }, + ); + validate(line.unitCost, ({ value }) => + !value() || canonicalExactDecimal(value()) !== null + ? null + : { + kind: 'cost', + message: $localize`:@@procurement.cost.invalid:Enter a nonnegative amount with at most six decimals.`, + }, + ); + maxLength(line.name, 160); + maxLength(line.brand, 255); + maxLength(line.model, 255); + }); + }); + + /** + * Property supplierLabels + * @readonly + * + * @description + * Retained labels remain readable when the server picker changes page. + * + * @access private + * @since unreleased + * + * @type {WritableSignal>>} + */ + private readonly supplierLabels: WritableSignal>> = signal({}); + + /** + * Property remoteFilter + * @readonly + * + * @description + * No client filter on an already server-filtered supplier page. + * + * @access protected + * @since unreleased + * + * @type {() => boolean} + */ + protected readonly remoteFilter: () => boolean = () => true; + + /** + * Property supplierLabelOf + * @readonly + * + * @description + * Native reference resolver for supplier option values. + * + * @access protected + * @since unreleased + * + * @type {(value: string) => string} + */ + protected readonly supplierLabelOf: (value: string) => string = (value) => + this.supplierLabels()[value] ?? value; + + /** + * Property typeLabelOf + * @readonly + * + * @description + * Native type resolver includes retained historical labels. + * + * @access protected + * @since unreleased + * + * @type {(value: string) => string} + */ + protected readonly typeLabelOf: (value: string) => string = (value) => + this.typeOptions().find((item) => item.value === value)?.label ?? value; + + /** + * Property kinds + * @readonly + * + * @description + * Explicit stock versus individual park choices. + * + * @access protected + * @since unreleased + * + * @type {readonly { value: ProcurementLineKind; label: string }[]} + */ + protected readonly kinds: readonly { value: ProcurementLineKind; label: string }[] = [ + { value: 'part', label: $localize`:@@procurement.kind.part:Stock article` }, + { + value: 'equipment_to_individualize', + label: $localize`:@@procurement.kind.equipment:Equipment to individualize`, + }, + ]; + + /** + * Property kindLabelOf + * @readonly + * + * @description + * Native reference resolver for line kinds. + * + * @access protected + * @since unreleased + * + * @type {(value: string) => string} + */ + protected readonly kindLabelOf: (value: string) => string = (value) => + this.kinds.find((item) => item.value === value)?.label ?? value; + + /** + * Property activeSuppliers + * @readonly + * + * @description + * Archival flags only prevent new assignments; history retains its labels. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly activeSuppliers: Signal = computed(() => + this.suppliers().filter((item) => !item.archivedAt), + ); + + /** + * Property seededIdentity + * + * @description + * Seeding follows identity, preserving typed edits during revision refreshes. + * + * @access private + * @since unreleased + * + * @type {string | null | undefined} + */ + private seededIdentity: string | null | undefined = undefined; + + //#endregion + + //#region Constructor + /** + * Constructor + * @constructor + * + * @description + * Seeds new or existing drafts once and reports the native form's dirtiness. + * + * @access public + * @since unreleased + */ + public constructor() { + effect(() => { + const needed: boolean = this.draft().lines.some( + (line) => line.kind === 'equipment_to_individualize', + ); + if (needed) untracked(() => this.equipmentTypesRequested.emit()); + }); + effect(() => { + const supplier = this.selectedSupplier(); + const choices = supplier ? [...this.suppliers(), supplier] : this.suppliers(); + this.supplierLabels.update((previous) => ({ + ...previous, + ...Object.fromEntries( + choices.map((item) => [item.id, item.code ? `${item.code} — ${item.name}` : item.name]), + ), + })); + }); + effect(() => { + const order = this.order(); + const identity = order?.id ?? null; + if (identity === this.seededIdentity) return; + this.seededIdentity = identity; + untracked(() => + this.orderForm().reset( + order + ? { + name: order.name, + supplierId: order.supplierId, + lines: order.lines.map((line) => { + const identityTemplate: Record = Array.isArray( + line.identityTemplate, + ) + ? {} + : { ...line.identityTemplate }; + return { + id: line.id, + kind: line.kind, + partId: line.partId ?? '', + typeCode: line.typeCode ?? '', + quantity: line.quantity, + unitCost: line.unitCost ?? '', + name: + typeof identityTemplate['name'] === 'string' ? identityTemplate['name'] : '', + brand: + typeof identityTemplate['brand'] === 'string' + ? identityTemplate['brand'] + : '', + model: + typeof identityTemplate['model'] === 'string' + ? identityTemplate['model'] + : '', + identityTemplate, + }; + }), + } + : { name: '', supplierId: '', lines: [newLine()] }, + ), + ); + }); + effect(() => { + const dirty = this.orderForm().dirty(); + untracked(() => this.dirtyChanged.emit(dirty)); + }); + } + + //#endregion + + //#region Methods + /** + * Method addLine + * @method addLine + * + * @description + * Adds a local line without posting or changing any inventory. + * + * @access protected + * @since unreleased + * + * @returns {void} No return value; emits or updates the local draft only. + */ + protected addLine(): void { + if (this.pending() || this.draft().lines.length >= 100) return; + this.draft.update((draft) => ({ ...draft, lines: [...draft.lines, newLine()] })); + this.orderForm().markAsDirty(); + } + + /** + * Method removeLine + * @method removeLine + * + * @description + * Removes only a local draft line. + * + * @access protected + * @since unreleased + * + * @param {number} index - Position of the local draft item to remove. + * + * @returns {void} No return value; emits or updates the local draft only. + */ + protected removeLine(index: number): void { + if (this.pending()) return; + this.draft.update((draft) => ({ + ...draft, + lines: draft.lines.filter((_, position) => position !== index), + })); + this.orderForm().markAsDirty(); + } + + /** + * Method selectableTypes + * @method selectableTypes + * + * @description + * Retains the current type label while limiting new choices to active catalogue entries. + * + * @access protected + * @since unreleased + * + * @param {string} value - Current selected value whose historical label remains readable. + * + * @returns {readonly EquipmentTypeOption[]} Active types and the retained historical selection. + */ + protected selectableTypes(value: string): readonly EquipmentTypeOption[] { + return this.typeOptions().filter((item) => !item.archived || item.value === value); + } + + /** + * Method submit + * @method submit + * + * @description + * Emits normalized exact values and omits costs the current user cannot manage. + * + * @access protected + * @since unreleased + * + * @param {Event} event - Native form submission event. + * + * @returns {void} No return value; emits or updates the local draft only. + */ + protected submit(event: Event): void { + event.preventDefault(); + this.orderForm().markAsTouched(); + if (this.pending() || this.orderForm().invalid()) return; + const draft: PurchaseOrderDraft = this.draft(); + const lines: PurchaseOrderLineInput[] = []; + for (const [index, line] of draft.lines.entries()) { + const quantity: string | null = canonicalExactDecimal(line.quantity); + if (quantity === null) return; + const costUpdated: boolean = + this.canEditCosts() && this.orderForm.lines[index].unitCost().dirty(); + const unitCost: string | null = line.unitCost.trim() + ? canonicalExactDecimal(line.unitCost) + : null; + if (costUpdated && line.unitCost.trim() && unitCost === null) return; + lines.push({ + id: line.id, + kind: line.kind, + quantity, + ...(line.kind === 'part' + ? { partId: line.partId } + : { + typeCode: line.typeCode, + identityTemplate: Object.assign({}, line.identityTemplate, { + name: line.name.trim() || null, + brand: line.brand.trim() || null, + model: line.model.trim() || null, + }), + }), + ...(costUpdated ? { unitCost } : {}), + }); + } + this.submitted.emit({ + name: draft.name.trim(), + supplierId: draft.supplierId, + lines, + }); + } + //#endregion +} diff --git a/src/app/features/organization/features/procurement/ui/forms/purchase-order-form/testing/purchase-order-form.component.spec.ts b/src/app/features/organization/features/procurement/ui/forms/purchase-order-form/testing/purchase-order-form.component.spec.ts new file mode 100644 index 000000000..f591a4d71 --- /dev/null +++ b/src/app/features/organization/features/procurement/ui/forms/purchase-order-form/testing/purchase-order-form.component.spec.ts @@ -0,0 +1,240 @@ +import { PLATFORM_ID, type WritableSignal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import type { FieldTree } from '@angular/forms/signals'; +import { InventoryService } from '@features/organization/features/inventory/data-access'; +import type { + PurchaseOrderLineOutput, + PurchaseOrderOutput, +} from '@features/organization/features/procurement/models'; +import type { + PurchaseOrderDraft, + PurchaseOrderLineDraft, +} from '../models/purchase-order-draft.interface'; +import { PurchaseOrderForm } from '../purchase-order-form.component'; + +describe('PurchaseOrderForm', () => { + const organizationId = 'org'; + const sourceLine: PurchaseOrderLineOutput = { + id: 'line', + kind: 'part', + partId: 'part', + quantity: '2.000000', + unitCost: '12.500000', + receivedQuantity: '0.000000', + returnedQuantity: '0.000000', + remainingQuantity: '2.000000', + identityTemplate: {}, + }; + const order: PurchaseOrderOutput = { + '@id': '/orders/order', + '@type': 'PurchaseOrder', + id: 'order', + organizationId: 'org', + supplierId: 'supplier', + name: 'Fire supplies', + currency: 'EUR', + status: 'draft', + lines: [sourceLine], + financialVisible: true, + revision: 2, + createdAt: '2026-10-05T10:00:00Z', + updatedAt: '2026-10-05T10:00:00Z', + replayed: false, + }; + const draftLine: PurchaseOrderLineDraft = { + id: 'line', + kind: 'part', + partId: 'part', + typeCode: '', + quantity: '0.25', + unitCost: '', + name: '', + brand: '', + model: '', + identityTemplate: {}, + }; + + beforeAll(() => { + globalThis.ResizeObserver ??= class { + observe(): void {} + unobserve(): void {} + disconnect(): void {} + } as unknown as typeof ResizeObserver; + }); + + async function setup(existing: PurchaseOrderOutput | null = null, canEditCosts = false) { + TestBed.configureTestingModule({ + imports: [PurchaseOrderForm], + providers: [ + { provide: PLATFORM_ID, useValue: 'server' }, + { provide: InventoryService, useValue: {} }, + ], + }); + const fixture = TestBed.createComponent(PurchaseOrderForm); + fixture.componentRef.setInput('organizationId', organizationId); + fixture.componentRef.setInput('order', existing); + fixture.componentRef.setInput('canEditCosts', canEditCosts); + const emitted = vi.fn(); + fixture.componentInstance.submitted.subscribe(emitted); + await fixture.whenStable(); + const form = fixture.componentInstance as unknown as { + draft: WritableSignal; + orderForm: FieldTree; + submit: (event: Event) => void; + }; + return { fixture, form, emitted }; + } + + it('emits six-place quantity strings and retains the stable draft line identity', async () => { + const { fixture, form, emitted } = await setup(); + form.draft.set({ name: ' Parts ', supplierId: 'supplier', lines: [draftLine] }); + await fixture.whenStable(); + form.submit(new Event('submit')); + expect(emitted).toHaveBeenCalledExactlyOnceWith({ + name: 'Parts', + supplierId: 'supplier', + lines: [{ id: 'line', kind: 'part', partId: 'part', quantity: '0.250000' }], + }); + }); + + it.each(['0', '-1', '1e3', '1,25', '0.0000001', '100000.000001'])( + 'rejects invalid order quantity %s without losing the input', + async (quantity) => { + const { fixture, form, emitted } = await setup(); + form.draft.set({ + name: 'Parts', + supplierId: 'supplier', + lines: [{ ...draftLine, quantity }], + }); + await fixture.whenStable(); + form.submit(new Event('submit')); + expect(emitted).not.toHaveBeenCalled(); + expect(form.draft().lines[0].quantity).toBe(quantity); + }, + ); + + it('requires only the reference for the chosen kind and whole units for equipment', async () => { + const { fixture, form, emitted } = await setup(); + const equipment = { + ...draftLine, + kind: 'equipment_to_individualize' as const, + partId: '', + typeCode: '', + quantity: '2', + }; + form.draft.set({ name: 'Reserve', supplierId: 'supplier', lines: [equipment] }); + await fixture.whenStable(); + form.submit(new Event('submit')); + expect(emitted).not.toHaveBeenCalled(); + form.draft.set({ + name: 'Reserve', + supplierId: 'supplier', + lines: [{ ...equipment, typeCode: 'EXTINGUISHER', quantity: '2.5' }], + }); + await fixture.whenStable(); + form.submit(new Event('submit')); + expect(emitted).not.toHaveBeenCalled(); + form.draft.update((draft) => ({ + ...draft, + lines: [{ ...draft.lines[0], quantity: '2' }], + })); + await fixture.whenStable(); + form.submit(new Event('submit')); + expect(emitted).toHaveBeenCalledWith({ + name: 'Reserve', + supplierId: 'supplier', + lines: [ + { + id: 'line', + kind: 'equipment_to_individualize', + typeCode: 'EXTINGUISHER', + quantity: '2.000000', + identityTemplate: { name: null, brand: null, model: null }, + }, + ], + }); + }); + + it('omits hidden financial fields even if the retained projection contains a cost', async () => { + const { fixture, form, emitted } = await setup(order, false); + await fixture.whenStable(); + form.submit(new Event('submit')); + expect(emitted.mock.calls[0][0].lines[0]).toEqual({ + id: 'line', + kind: 'part', + partId: 'part', + quantity: '2.000000', + }); + expect(fixture.nativeElement.querySelector('#procurement-line-cost-0')).toBeNull(); + }); + + it('omits an unchanged cost while preserving an explicit authorized clear', async () => { + const { fixture, form, emitted } = await setup(order, true); + form.submit(new Event('submit')); + expect(emitted.mock.calls[0][0].lines[0]).not.toHaveProperty('unitCost'); + const cost: HTMLInputElement = fixture.nativeElement.querySelector('#procurement-line-cost-0'); + cost.value = ''; + cost.dispatchEvent(new Event('input', { bubbles: true })); + await fixture.whenStable(); + form.submit(new Event('submit')); + expect(emitted.mock.calls[1][0].lines[0]).toMatchObject({ unitCost: null }); + }); + + it('emits an authorized edited cost exactly without floating-point rounding', async () => { + const { fixture, form, emitted } = await setup(order, true); + const cost: HTMLInputElement = fixture.nativeElement.querySelector('#procurement-line-cost-0'); + cost.value = '999999999999.123456'; + cost.dispatchEvent(new Event('input', { bubbles: true })); + await fixture.whenStable(); + form.submit(new Event('submit')); + expect(emitted.mock.calls[0][0].lines[0]).toMatchObject({ + unitCost: '999999999999.123456', + }); + }); + + it('retains unknown declarative identity fields and failed edits across a revision refresh', async () => { + const source: PurchaseOrderOutput = { + ...order, + lines: [ + { + ...sourceLine, + kind: 'equipment_to_individualize', + partId: null, + typeCode: 'EXTINGUISHER', + identityTemplate: { + name: 'Extinguisher', + serialNumber: 'S-101', + properties: { capacity: '6' }, + }, + }, + ], + }; + const { fixture, form, emitted } = await setup(source); + form.draft.update((draft) => ({ + ...draft, + name: 'Edited draft', + lines: [{ ...draft.lines[0], brand: ' Manufacturer ' }], + })); + fixture.componentRef.setInput('error', 'Review the latest purchase revision.'); + fixture.componentRef.setInput('order', { ...source, revision: 3, name: 'Server name' }); + await fixture.whenStable(); + form.submit(new Event('submit')); + expect(emitted.mock.calls[0][0]).toMatchObject({ + name: 'Edited draft', + lines: [ + { + identityTemplate: { + name: 'Extinguisher', + brand: 'Manufacturer', + model: null, + serialNumber: 'S-101', + properties: { capacity: '6' }, + }, + }, + ], + }); + expect(fixture.nativeElement.querySelector('[role="alert"]').textContent).toContain( + 'Review the latest purchase revision.', + ); + }); +}); diff --git a/src/app/features/organization/features/procurement/ui/forms/supplier-form/index.ts b/src/app/features/organization/features/procurement/ui/forms/supplier-form/index.ts new file mode 100644 index 000000000..ce8025593 --- /dev/null +++ b/src/app/features/organization/features/procurement/ui/forms/supplier-form/index.ts @@ -0,0 +1 @@ +export { SupplierForm } from './supplier-form.component'; diff --git a/src/app/features/organization/features/procurement/ui/forms/supplier-form/supplier-form.component.html b/src/app/features/organization/features/procurement/ui/forms/supplier-form/supplier-form.component.html new file mode 100644 index 000000000..469618622 --- /dev/null +++ b/src/app/features/organization/features/procurement/ui/forms/supplier-form/supplier-form.component.html @@ -0,0 +1,199 @@ +
+
+ Supplier information +
+
+ + + @if (supplierForm.name().touched()) { + @for (error of supplierForm.name().errors(); track error) { + {{ error.message }} + } + } +
+
+ + + @if (supplierForm.code().touched()) { + @for (error of supplierForm.code().errors(); track error) { + {{ error.message }} + } + } +
+
+ + + @if (supplierForm.email().touched()) { + @for (error of supplierForm.email().errors(); track error) { + {{ error.message }} + } + } +
+
+ + + @if (supplierForm.phone().touched()) { + @for (error of supplierForm.phone().errors(); track error) { + {{ error.message }} + } + } +
+
+
+
+ Contacts +

+ Contacts are internal records. They do not receive access to FireGuard. +

+ @for (contact of draft().contacts; track $index; let index = $index) { +
+ Contact +
+
+ + + @if (supplierForm.contacts[index].name().touched()) { + @for (error of supplierForm.contacts[index].name().errors(); track error) { + {{ error.message }} + } + } +
+
+ + + @if (supplierForm.contacts[index].role().touched()) { + @for (error of supplierForm.contacts[index].role().errors(); track error) { + {{ error.message }} + } + } +
+
+ + + @if (supplierForm.contacts[index].email().touched()) { + @for (error of supplierForm.contacts[index].email().errors(); track error) { + {{ error.message }} + } + } +
+
+ + + @if (supplierForm.contacts[index].phone().touched()) { + @for (error of supplierForm.contacts[index].phone().errors(); track error) { + {{ error.message }} + } + } +
+ +
+
+ } + @for (error of supplierForm.contacts().errors(); track error) { + {{ error.message }} + } + +
+ @if (error(); as message) { +
+

Supplier could not be saved

+

{{ message }}

+
+ } +
+ + +
+
diff --git a/src/app/features/organization/features/procurement/ui/forms/supplier-form/supplier-form.component.ts b/src/app/features/organization/features/procurement/ui/forms/supplier-form/supplier-form.component.ts new file mode 100644 index 000000000..fc896992b --- /dev/null +++ b/src/app/features/organization/features/procurement/ui/forms/supplier-form/supplier-form.component.ts @@ -0,0 +1,552 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + input, + output, + signal, + untracked, + type InputSignal, + type OutputEmitterRef, + type Signal, + type WritableSignal, +} from '@angular/core'; +import { + applyEach, + disabled, + email, + form, + FormField, + maxLength, + required, + validate, + type FieldTree, +} from '@angular/forms/signals'; +import type { + ChangeSupplierInput, + SupplierOutput, +} from '@features/organization/features/procurement/models'; +import { HlmAlertImports } from '@shared/ui/alert'; +import { HlmButton } from '@shared/ui/button'; +import { HlmFieldImports } from '@shared/ui/field'; +import { HlmInput } from '@shared/ui/input'; +import { HlmSpinner } from '@shared/ui/spinner'; + +/** + * Interface ContactDraft + * @interface ContactDraft + * + * @description + * Editable contact fields remain strings until a validated submission. + * + * @since unreleased + */ +interface ContactDraft { + /** + * Property name + * + * @description + * Display name; whitespace-only input is rejected before submission. + * + * @access public + * @since unreleased + * + * @type {string} + */ + name: string; + + /** + * Property email + * + * @description + * Optional email address validated before submission. + * + * @access public + * @since unreleased + * + * @type {string} + */ + email: string; + + /** + * Property phone + * + * @description + * Optional telephone number; no assumed country format. + * + * @access public + * @since unreleased + * + * @type {string} + */ + phone: string; + + /** + * Property role + * + * @description + * Optional responsibility of the internal supplier contact. + * + * @access public + * @since unreleased + * + * @type {string} + */ + role: string; +} +/** + * Interface SupplierDraft + * @interface SupplierDraft + * + * @description + * Editable supplier fields and contacts owned exclusively by the form. + * + * @since unreleased + */ +interface SupplierDraft { + /** + * Property name + * + * @description + * Display name; whitespace-only input is rejected before submission. + * + * @access public + * @since unreleased + * + * @type {string} + */ + name: string; + + /** + * Property code + * + * @description + * Optional internal supplier reference. + * + * @access public + * @since unreleased + * + * @type {string} + */ + code: string; + + /** + * Property email + * + * @description + * Optional email address validated before submission. + * + * @access public + * @since unreleased + * + * @type {string} + */ + email: string; + + /** + * Property phone + * + * @description + * Optional telephone number; no assumed country format. + * + * @access public + * @since unreleased + * + * @type {string} + */ + phone: string; + + /** + * Property contacts + * + * @description + * Internal contacts retained with the supplier, without external account access. + * + * @access public + * @since unreleased + * + * @type {ContactDraft[]} + */ + contacts: ContactDraft[]; +} +/** + * Function emptyDraft + * + * @description + * Creates an empty internal supplier draft. + * + * @access public + * @since unreleased + * + * @returns {SupplierDraft} Result for the owning supplier workflow. + */ +function emptyDraft(): SupplierDraft { + return { name: '', code: '', email: '', phone: '', contacts: [] }; +} +/** + * Function optional + * + * @description + * Maps optional blank fields to explicit clears accepted by merge patch. + * + * @access public + * @since unreleased + * + * @param {string} value - Value supplied by the owning supplier workflow. + * + * @returns {string | null} Result for the owning supplier workflow. + */ +function optional(value: string): string | null { + return value.trim() || null; +} + +/** + * Class SupplierForm + * @class SupplierForm + * + * @description + * Native supplier form preserving failed drafts and emitting only validated transport input. + * + * @since unreleased + */ +@Component({ + selector: 'app-supplier-form', + templateUrl: './supplier-form.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [FormField, HlmButton, HlmInput, HlmSpinner, ...HlmFieldImports, ...HlmAlertImports], +}) +export class SupplierForm { + //#region Properties + /** + * Property supplier + * @readonly + * + * @description + * Existing supplier or an empty creation draft. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly supplier: InputSignal = input(null); + + /** + * Property pending + * @readonly + * + * @description + * Locks all editors during an accepted write. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly pending: InputSignal = input(false); + + /** + * Property error + * @readonly + * + * @description + * Normalized server rejection, displayed without discarding input. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly error: InputSignal = input(null); + + /** + * Property submitted + * @readonly + * + * @description + * Validated supplier payload. + * + * @access public + * @since unreleased + * + * @type {OutputEmitterRef} + */ + public readonly submitted: OutputEmitterRef = output(); + + /** + * Property cancelled + * @readonly + * + * @description + * Requests dismissal; the hosting sheet owns confirmation. + * + * @access public + * @since unreleased + * + * @type {OutputEmitterRef} + */ + public readonly cancelled: OutputEmitterRef = output(); + + /** + * Property dirtyChanged + * @readonly + * + * @description + * Actual Signal Forms dirty state used by the host. + * + * @access public + * @since unreleased + * + * @type {OutputEmitterRef} + */ + public readonly dirtyChanged: OutputEmitterRef = output(); + + /** + * Property draft + * @readonly + * + * @description + * Supplier and contact drafts. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal} + */ + protected readonly draft: WritableSignal = signal(emptyDraft()); + + /** + * Property supplierForm + * @readonly + * + * @description + * Native field validation and submission state. + * + * @access protected + * @since unreleased + * + * @type {FieldTree} + */ + protected readonly supplierForm: FieldTree = form(this.draft, (path) => { + disabled(path, () => this.pending()); + validate(path.contacts, ({ value }) => + value().length <= 50 + ? null + : { + kind: 'maxLength', + message: $localize`:@@procurement.supplier.contactsLimit:Keep at most 50 contacts per supplier.`, + }, + ); + required(path.name, { + message: $localize`:@@procurement.supplier.nameRequired:Enter the supplier name.`, + }); + validate(path.name, ({ value }) => + value().trim() + ? null + : { + kind: 'required', + message: $localize`:@@procurement.supplier.nameRequired:Enter the supplier name.`, + }, + ); + maxLength(path.name, 160, { + message: $localize`:@@procurement.supplier.max160:Use at most 160 characters.`, + }); + maxLength(path.code, 80, { + message: $localize`:@@procurement.supplier.max80:Use at most 80 characters.`, + }); + maxLength(path.email, 254, { + message: $localize`:@@procurement.supplier.emailInvalid:Enter a valid email address.`, + }); + email(path.email, { + message: $localize`:@@procurement.supplier.emailInvalid:Enter a valid email address.`, + }); + maxLength(path.phone, 40, { + message: $localize`:@@procurement.supplier.max40:Use at most 40 characters.`, + }); + applyEach(path.contacts, (contact) => { + required(contact.name, { + message: $localize`:@@procurement.supplier.contactRequired:Enter the contact name.`, + }); + validate(contact.name, ({ value }) => + value().trim() + ? null + : { + kind: 'required', + message: $localize`:@@procurement.supplier.contactRequired:Enter the contact name.`, + }, + ); + maxLength(contact.name, 160, { + message: $localize`:@@procurement.supplier.max160:Use at most 160 characters.`, + }); + maxLength(contact.email, 254, { + message: $localize`:@@procurement.supplier.emailInvalid:Enter a valid email address.`, + }); + email(contact.email, { + message: $localize`:@@procurement.supplier.emailInvalid:Enter a valid email address.`, + }); + maxLength(contact.phone, 40, { + message: $localize`:@@procurement.supplier.max40:Use at most 40 characters.`, + }); + maxLength(contact.role, 80, { + message: $localize`:@@procurement.supplier.max80:Use at most 80 characters.`, + }); + }); + }); + + /** + * Property submitLabel + * @readonly + * + * @description + * Creation and edition share one truthful submit caption. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly submitLabel: Signal = computed(() => + this.supplier() + ? $localize`:@@procurement.supplier.save:Save supplier` + : $localize`:@@procurement.supplier.create:Create supplier`, + ); + + /** + * Property seededIdentity + * + * @description + * Identity already seeded; revision refreshes must not replace the entered draft. + * + * @access private + * @since unreleased + * + * @type {string | null | undefined} + */ + private seededIdentity: string | null | undefined = undefined; + //#endregion + + //#region Constructor + /** + * Constructor + * @constructor + * + * @description + * Seeds only when record identity changes; server rejection never resets the draft. + * + * @access public + * @since unreleased + */ + public constructor() { + effect(() => { + const supplier = this.supplier(); + const identity = supplier?.id ?? null; + if (this.seededIdentity === identity) return; + this.seededIdentity = identity; + untracked(() => { + this.supplierForm().reset( + supplier + ? { + name: supplier.name, + code: supplier.code ?? '', + email: supplier.email ?? '', + phone: supplier.phone ?? '', + contacts: supplier.contacts.map((contact) => ({ + name: contact.name, + email: contact.email ?? '', + phone: contact.phone ?? '', + role: contact.role ?? '', + })), + } + : emptyDraft(), + ); + }); + }); + effect(() => { + const dirty = this.supplierForm().dirty(); + untracked(() => this.dirtyChanged.emit(dirty)); + }); + } + //#endregion + + //#region Methods + /** + * Method addContact + * @method addContact + * + * @description + * Adds a contact without changing the server directory. + * + * @access protected + * @since unreleased + * + * @returns {void} No return value. + */ + protected addContact(): void { + if (!this.pending() && this.draft().contacts.length < 50) { + this.draft.update((draft) => ({ + ...draft, + contacts: [...draft.contacts, { name: '', email: '', phone: '', role: '' }], + })); + this.supplierForm().markAsDirty(); + } + } + + /** + * Method removeContact + * @method removeContact + * + * @description + * Removes one local contact while keeping the remaining draft. + * + * @access protected + * @since unreleased + * + * @param {number} index - Value supplied by the owning supplier workflow. + * + * @returns {void} No return value. + */ + protected removeContact(index: number): void { + if (!this.pending()) { + this.draft.update((draft) => ({ + ...draft, + contacts: draft.contacts.filter((_, position) => position !== index), + })); + this.supplierForm().markAsDirty(); + } + } + + /** + * Method submit + * @method submit + * + * @description + * Exposes errors before emitting normalized supplier and contact data. + * + * @access protected + * @since unreleased + * + * @param {Event} event - Value supplied by the owning supplier workflow. + * + * @returns {void} No return value. + */ + protected submit(event: Event): void { + event.preventDefault(); + this.supplierForm().markAsTouched(); + if (this.pending() || this.supplierForm().invalid()) return; + const draft = this.draft(); + this.submitted.emit({ + name: draft.name.trim(), + code: optional(draft.code), + email: optional(draft.email), + phone: optional(draft.phone), + contacts: draft.contacts.map((contact) => ({ + name: contact.name.trim(), + email: optional(contact.email), + phone: optional(contact.phone), + role: optional(contact.role), + })), + }); + } + //#endregion +} diff --git a/src/app/features/organization/features/procurement/ui/forms/supplier-form/testing/supplier-form.component.spec.ts b/src/app/features/organization/features/procurement/ui/forms/supplier-form/testing/supplier-form.component.spec.ts new file mode 100644 index 000000000..ecde19f47 --- /dev/null +++ b/src/app/features/organization/features/procurement/ui/forms/supplier-form/testing/supplier-form.component.spec.ts @@ -0,0 +1,129 @@ +import type { WritableSignal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import type { SupplierOutput } from '@features/organization/features/procurement/models'; +import { SupplierForm } from '../supplier-form.component'; + +describe('SupplierForm', () => { + const supplier: SupplierOutput = { + '@id': '/suppliers/supplier', + '@type': 'Supplier', + id: 'supplier', + organizationId: 'org', + name: 'Fire supplies', + code: 'SUP-1', + contacts: [{ name: 'Mary', email: 'mary@example.com', role: 'Parts' }], + revision: 3, + createdAt: '2026-10-05T10:00:00Z', + updatedAt: '2026-10-05T10:00:00Z', + replayed: false, + }; + type Draft = { + name: string; + code: string; + email: string; + phone: string; + contacts: { name: string; email: string; phone: string; role: string }[]; + }; + const validDraft: Draft = { + name: ' Fire supplies ', + code: ' SUP-1 ', + email: '', + phone: ' +33 1 23 45 67 89 ', + contacts: [{ name: ' Mary ', email: 'mary@example.com', phone: '', role: ' Parts ' }], + }; + + async function setup(existing: SupplierOutput | null = null) { + TestBed.configureTestingModule({ imports: [SupplierForm] }); + const fixture = TestBed.createComponent(SupplierForm); + fixture.componentRef.setInput('supplier', existing); + const emitted = vi.fn(); + fixture.componentInstance.submitted.subscribe(emitted); + await fixture.whenStable(); + const form = fixture.componentInstance as unknown as { + draft: WritableSignal; + submit: (event: Event) => void; + addContact: () => void; + removeContact: (index: number) => void; + }; + return { fixture, form, emitted }; + } + + it('emits trimmed internal contacts and explicit clears without creating external accounts', async () => { + const { fixture, form, emitted } = await setup(); + form.draft.set(validDraft); + await fixture.whenStable(); + form.submit(new Event('submit')); + expect(emitted).toHaveBeenCalledExactlyOnceWith({ + name: 'Fire supplies', + code: 'SUP-1', + email: null, + phone: '+33 1 23 45 67 89', + contacts: [{ name: 'Mary', email: 'mary@example.com', phone: null, role: 'Parts' }], + }); + }); + + it.each([ + { name: ' ', email: 'mary@example.com' }, + { name: 'Mary', email: 'invalid-email' }, + ])('rejects an invalid contact $name / $email before emitting', async (contact) => { + const { fixture, form, emitted } = await setup(); + form.draft.set({ ...validDraft, contacts: [{ ...contact, phone: '', role: '' }] }); + await fixture.whenStable(); + form.submit(new Event('submit')); + expect(emitted).not.toHaveBeenCalled(); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('hlm-field-error')).not.toBeNull(); + }); + + it('preserves entered contacts and displays a recoverable error across a revision refresh', async () => { + const { fixture, form, emitted } = await setup(supplier); + form.draft.set(validDraft); + fixture.componentRef.setInput('error', 'Review the latest supplier revision.'); + fixture.componentRef.setInput('supplier', { ...supplier, revision: 4, name: 'Server name' }); + await fixture.whenStable(); + expect(form.draft()).toEqual(validDraft); + expect(fixture.nativeElement.querySelector('[role="alert"]').textContent).toContain( + 'Review the latest supplier revision.', + ); + form.submit(new Event('submit')); + expect(emitted).toHaveBeenCalledOnce(); + fixture.componentRef.setInput('supplier', { + ...supplier, + id: 'another', + name: 'Another supplier', + }); + await fixture.whenStable(); + expect(form.draft().name).toBe('Another supplier'); + expect(form.draft().contacts[0].name).toBe('Mary'); + }); + + it('locks submissions and contact editing while the owner resolves an accepted write', async () => { + const { fixture, form, emitted } = await setup(); + form.draft.set(validDraft); + fixture.componentRef.setInput('pending', true); + await fixture.whenStable(); + form.submit(new Event('submit')); + form.addContact(); + form.removeContact(0); + expect(emitted).not.toHaveBeenCalled(); + expect(form.draft()).toEqual(validDraft); + expect(fixture.nativeElement.querySelector('button[type="submit"]').disabled).toBe(true); + }); + + it('bounds the supplier contact directory to fifty entries', async () => { + const { fixture, form, emitted } = await setup(); + const contacts = Array.from({ length: 50 }, (_, index) => ({ + name: `Contact ${index + 1}`, + email: '', + phone: '', + role: '', + })); + form.draft.set({ ...validDraft, contacts }); + form.addContact(); + expect(form.draft().contacts).toHaveLength(50); + form.draft.set({ ...validDraft, contacts: [...contacts, contacts[0]] }); + await fixture.whenStable(); + form.submit(new Event('submit')); + expect(emitted).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.html b/src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.html new file mode 100644 index 000000000..b9c82d179 --- /dev/null +++ b/src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.html @@ -0,0 +1,1064 @@ +
+ + + + + + +
+

+ Prepare purchases, record physical deliveries and follow reserve equipment or stock without + losing the source history. +

+ @if (store.uncertainCommand() && !editor() && !confirmation()) { +
+

+ Recover the original operation result +

+

+ The response was lost. Retry the same declaration to recover its result before entering + another physical operation. +

+ +
+ } + @if (commandError() && !editor() && !confirmation()) { +
+

Operation could not be confirmed

+

{{ commandError() }}

+ +
+ } + @if (view() === 'suppliers') { +
+ + + + @for (option of supplierFilters; track option.value) { + {{ option.label }} + } + + @if (canManage()) { + + } +
+ @if (store.suppliersCallState().error; as error) { +
+
+

+ Suppliers could not be loaded +

+

{{ error.message }}

+
+
+ +
+
+ } + @if ( + store.suppliersCallState().status === 'pending' && store.supplierEntities().length === 0 + ) { + + } + @if (store.suppliersCallState().status === 'success' && store.totalSuppliers() === 0) { +
+
+ +

No suppliers match this view

+

+ Adjust your search or create an internal supplier. +

+
+
+ } +
+ @for (supplier of store.supplierEntities(); track supplier.id; let last = $last) { +
+
+
+ {{ supplier.name }} + @if (supplier.archivedAt) { + Archived + } +
+
+ @if (supplier.code) { + {{ supplier.code }} + } + @if (supplier.email) { + {{ supplier.email }} + } + @if (supplier.phone) { + {{ supplier.phone }} + } +
+
+ @if (canManage()) { +
+ + @if (!supplier.archivedAt) { + + } +
+ } +
+ @if (!last) { +
+ } + } +
+ + } @else { +
+ + @for (status of orderStatuses; track status.value) { + {{ status.label }} + } + + @if (canManage()) { + + } +
+ @if (store.ordersCallState().error; as error) { +
+
+

+ Purchase orders could not be loaded +

+

{{ error.message }}

+
+
+ +
+
+ } + @if (store.ordersCallState().status === 'pending' && store.orderEntities().length === 0) { + + } + @if (store.ordersCallState().status === 'success' && store.totalOrders() === 0) { +
+
+ +

+ No purchase orders match this view +

+

+ Choose another status or prepare an internal purchase. +

+
+
+ } +
+ @for (order of store.orderEntities(); track order.id; let last = $last) { +
+
+
+ {{ + statusLabel('order', order.status) + }} +
+
+ Created: + {{ order.createdAt | appOrgDate: 'date' : regionalFormatting() }} +
+
+
+ +
+
+ @if (!last) { +
+ } + } +
+ + @if (store.orderCallState().error; as error) { +
+

+ Source order could not be loaded +

+

{{ error.message }}

+ +
+ } + @if (store.orderCallState().status === 'pending') { + + } + @if (store.selectedOrder(); as order) { + @if (order.id === orderId()) { +
+
+
+

{{ order.name }}

+
+ {{ + statusLabel('order', order.status) + }}{{ store.supplierCallState().data?.name }} + @if (store.supplierCallState().data?.archivedAt) { + Archived + } +
+
+ +
+
+ @if (canEditOrder(order)) { + + } + @if (canCancelOrder(order)) { + + } +
+ @if (catalog.loadCallState().error; as error) { +
+

+ Equipment types could not be loaded +

+

{{ error.message }}

+ +
+ } +
+ @for (line of order.lines; track line.id; let last = $last) { +
+
+
{{ lineTitle(line) }}
+
+ Ordered: {{ line.quantity }} {{ lineUnit(line) }}Physically received: {{ line.receivedQuantity }} + {{ lineUnit(line) }}Awaiting delivery: {{ line.remainingQuantity }} + {{ lineUnit(line) }}Physically returned: {{ line.returnedQuantity }} + {{ lineUnit(line) }} + @if (canReadCosts() && order.financialVisible) { + @if (line.unitCost != null) { + Internal unit cost: {{ line.unitCost }} {{ order.currency }} + } @else { + Internal cost incomplete — unit cost unknown. + } + } +
+
+ @if (canReceive(order, line)) { +
+ +
+ } +
+ @if (!last) { +
+ } + } +
+ @if (view() === 'orders') { + + } +
+ @if (view() === 'receipts') { +
+

+ Physical receipt history +

+ @if (store.receiptsCallState().error; as error) { +
+
+

+ Receipts could not be loaded +

+

{{ error.message }}

+
+
+ +
+
+ } + @if ( + store.receiptsCallState().status === 'pending' && + store.receiptEntities().length === 0 + ) { + + } + @if ( + store.receiptsCallState().status === 'success' && store.totalReceipts() === 0 + ) { +
+
+ +

+ No physical receipts recorded +

+

+ Record the actual delivery from its source line above. Placing an order does + not receive its goods. +

+
+
+ } +
+ @for (receipt of store.receiptEntities(); track receipt.id; let last = $last) { +
+
+
+ Received: {{ receipt.quantity }} {{ receiptUnit(receipt) }}{{ + statusLabel('receipt', receipt.status) + }} +
+
+ {{ + receipt.receivedAt | appOrgDate: 'datetime' : regionalFormatting() + }}Physically returned: {{ receipt.returnedQuantity }} + {{ receiptUnit(receipt) }} + @if (receipt.pendingReturnQuantity !== '0.000000') { + Return movement awaiting reconciliation: + {{ receipt.pendingReturnQuantity }} {{ receiptUnit(receipt) }} + } + @if (receipt.blockedReason) { + {{ blockedLabel(receipt.blockedReason) }} + } + @if (canReadCosts() && receipt.financialVisible) { + @if (receipt.unitCost != null) { + Internal unit cost: {{ receipt.unitCost }} + {{ receipt.currency }} + } @else { + Internal cost incomplete — unit cost unknown. + } + } + @for (equipmentId of receipt.equipmentIds; track equipmentId) { + Open reserve equipment dossier + } +
+
+
+ @if (canIndividualize(receipt)) { + + } + @if (canReturn(receipt)) { + + } + +
+
+ @if (!last) { +
+ } + } +
+ +
+ @if (returnsReceiptId()) { +
+

+ Physical returns and inventory reconciliation +

+

+ Each quantity and reason remains linked to its original receipt. Reconciliation + confirms a stock movement and never rewrites the physical declaration. +

+ @if (store.returnsCallState().error; as error) { +
+
+

+ Return history could not be loaded +

+

{{ error.message }}

+
+
+ +
+
+ } + @if ( + store.returnsCallState().status === 'pending' && + store.supplyReturnEntities().length === 0 + ) { + + } + @if ( + store.returnsCallState().status === 'success' && store.totalReturns() === 0 + ) { +
+
+ +

+ No physical returns recorded +

+
+
+ } +
+ @for ( + returned of store.supplyReturnEntities(); + track returned.id; + let last = $last + ) { +
+
+
+ Returned: {{ returned.quantity }}{{ + statusLabel('return', returned.status) + }} +
+
+ {{ returned.reason }}{{ + returned.createdAt | appOrgDate: 'datetime' : regionalFormatting() + }} + @if (returned.blockedReason) { + {{ blockedLabel(returned.blockedReason) }} + } +
+
+ @if (canReconcile(returned)) { +
+ +
+ } +
+ @if (!last) { +
+ } + } +
+ +
+ } + } + } + } @else { + @if (view() === 'receipts') { +
+
+ +

+ Select the source purchase order +

+

+ Open an order's receipts to keep its physical deliveries and returns together. +

+
+
+ } + } + } +
+
+
+ + +

{{ editorTitle() }}

+

+ Keep the source record and the actual physical quantities together. +

+
+ @if (store.uncertainCommand()) { +
+

+ Recover the original operation result +

+

+ The response was lost. Retry the same declaration to recover its result before entering + another physical operation. +

+ +
+ } + @if (revisionConflict() && !store.uncertainCommand()) { +
+

+ Review the latest source revision +

+

+ Your typed draft is retained. Load the latest source before choosing to use its + revision. +

+
+ +
+
+ } +
+ @switch (editor()) { + @case ('supplier') { + + } + @case ('order') { + @if (catalog.loadCallState().error; as error) { + + } + + } + @case ('receipt') { + @if (receivingLine(); as line) { + + } + } + @case ('return') { + @if (returningReceipt(); as receipt) { + + } + } + } +
+
+
+
+ +

{{ confirmationTitle() }}

+

{{ confirmationDescription() }}

+ @if (commandError()) { +
+

{{ commandError() }}

+
+ } + @if (store.uncertainCommand()) { + + } + @if (revisionConflict() && !store.uncertainCommand()) { +
+ +
+ } + +
+
+ diff --git a/src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.ts b/src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.ts new file mode 100644 index 000000000..df8b64bb3 --- /dev/null +++ b/src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.ts @@ -0,0 +1,2061 @@ +import { isPlatformBrowser } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + PLATFORM_ID, + afterNextRender, + computed, + effect, + inject, + input, + signal, + untracked, + type InputSignal, + type Signal, + type WritableSignal, +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { Router, RouterLink } from '@angular/router'; +import { Events } from '@ngrx/signals/events'; +import { OrganizationPermissionService } from '@features/organization/access'; +import { + EquipmentTypeCatalogStore, + type EquipmentTypeCatalogStoreType, +} from '@features/organization/features/equipments'; +import type { + ChangeSupplierInput, + ChangePurchaseOrderInput, + SupplierOutput, + PurchaseOrderOutput, + PurchaseOrderLineOutput, + ProcurementReceiptOutput, + ProcurementReturnOutput, + ReceivePurchaseOrderInput, + ReturnProcurementReceiptInput, +} from '@features/organization/features/procurement/models'; +import { procurementStatusLabel } from '@features/organization/features/procurement/models/procurement-tag/procurement-tag.util'; +import { + ProcurementStore, + procurementStoreEvents, + type ProcurementStoreType, +} from '@features/organization/features/procurement/state'; +import type { ProcurementCommand } from '@features/organization/features/procurement/state/procurement/models/procurement-command.type'; +import { + exactDecimalDifference, + exactDecimalUnits, +} from '@features/organization/features/procurement/utils'; +import { ORGANIZATION_PERMISSION } from '@features/organization/models'; +import { + REGIONAL_FORMATTING_PORT, + type RegionalFormattingPort, +} from '@features/organization/ports'; +import { CollectionPagination } from '@shared/collection-pagination'; +import { CollectionSearchBox } from '@shared/collection-toolbar'; +import { OrgDatePipe, type RegionalFormatSettings } from '@shared/regional-format'; +import { StateIllustration } from '@shared/state-illustration'; +import { HlmAlertImports } from '@shared/ui/alert'; +import { HlmAlertDialogImports } from '@shared/ui/alert-dialog'; +import { HlmBadge } from '@shared/ui/badge'; +import { HlmButton } from '@shared/ui/button'; +import { HlmEmptyImports } from '@shared/ui/empty'; +import { HlmItemImports } from '@shared/ui/item'; +import { HlmSelectImports } from '@shared/ui/select'; +import { HlmSheetImports } from '@shared/ui/sheet'; +import { HlmSkeleton } from '@shared/ui/skeleton'; +import { HlmSpinner } from '@shared/ui/spinner'; +import { HlmTabsImports } from '@shared/ui/tabs'; +import { UnsavedChangesDialog, type UnsavedChangesAware } from '@shared/unsaved-changes'; +import { ProcurementReceiptForm } from '../../forms/procurement-receipt-form'; +import { ProcurementReturnForm } from '../../forms/procurement-return-form'; +import { PurchaseOrderForm } from '../../forms/purchase-order-form'; +import { SupplierForm } from '../../forms/supplier-form'; + +/** + * Class ProcurementPage + * @class ProcurementPage + * + * @description + * Organization purchase workspace. Physical deliveries, individualization and returns stay + * distinct, permission-gated and linked to their original server records. + * + * @since unreleased + */ +@Component({ + selector: 'app-procurement-page', + templateUrl: './procurement-page.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [EquipmentTypeCatalogStore], + imports: [ + RouterLink, + OrgDatePipe, + StateIllustration, + CollectionSearchBox, + CollectionPagination, + SupplierForm, + PurchaseOrderForm, + ProcurementReceiptForm, + ProcurementReturnForm, + UnsavedChangesDialog, + HlmBadge, + HlmButton, + HlmSkeleton, + HlmSpinner, + ...HlmAlertImports, + ...HlmAlertDialogImports, + ...HlmEmptyImports, + ...HlmItemImports, + ...HlmSelectImports, + ...HlmSheetImports, + ...HlmTabsImports, + ], + host: { + class: 'flex min-h-0 min-w-0 flex-1 flex-col', + '(window:beforeunload)': 'beforeUnload($event)', + }, +}) +export class ProcurementPage implements UnsavedChangesAware { + /** + * Property organizationId + * @readonly + * + * @description + * Tenant resolved by the organization route and API access projection. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly organizationId: InputSignal = input.required(); + /** + * Property section + * @readonly + * + * @description + * Explicit navigable workspace section. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly section: InputSignal = input(); + /** + * Property orderId + * @readonly + * + * @description + * Selected source order, retained when opening physical receipts. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly orderId: InputSignal = input(); + /** + * Property store + * @readonly + * + * @description + * Route-scoped query and accepted command state. + * + * @access protected + * @since unreleased + * + * @type {ProcurementStoreType} + */ + protected readonly store: ProcurementStoreType = inject(ProcurementStore); + /** + * Property catalog + * @readonly + * + * @description + * Authorized dynamic equipment catalogue, including historical labels. + * + * @access protected + * @since unreleased + * + * @type {EquipmentTypeCatalogStoreType} + */ + protected readonly catalog: EquipmentTypeCatalogStoreType = inject(EquipmentTypeCatalogStore); + /** + * Property permissions + * @readonly + * + * @description + * API permissions remain the sole source of capabilities. + * + * @access private + * @since unreleased + * + * @type {OrganizationPermissionService} + */ + private readonly permissions: OrganizationPermissionService = inject( + OrganizationPermissionService, + ); + /** + * Property router + * @readonly + * + * @description + * URL state and guard-protected navigation. + * + * @access private + * @since unreleased + * + * @type {Router} + */ + private readonly router: Router = inject(Router); + /** + * Property events + * @readonly + * + * @description + * Confirmed mutation consequences. + * + * @access private + * @since unreleased + * + * @type {Events} + */ + private readonly events: Events = inject(Events); + /** + * Property destroyRef + * @readonly + * + * @description + * Subscription and browser event lifecycle. + * + * @access private + * @since unreleased + * + * @type {DestroyRef} + */ + private readonly destroyRef: DestroyRef = inject(DestroyRef); + /** + * Property formattingPort + * @readonly + * + * @description + * Organization date format for physical instants. + * + * @access private + * @since unreleased + * + * @type {RegionalFormattingPort} + */ + private readonly formattingPort: RegionalFormattingPort = inject(REGIONAL_FORMATTING_PORT); + /** + * Property regionalFormatting + * @readonly + * + * @description + * Typed organization presentation settings. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly regionalFormatting: Signal = + this.formattingPort.regionalFormatting; + /** + * Property browserReady + * @readonly + * + * @description + * Secondary authenticated reads begin only in the browser. + * + * @access private + * @since unreleased + * + * @type {WritableSignal} + */ + private readonly browserReady: WritableSignal = signal(false); + /** + * Property browser + * @readonly + * + * @description + * Explicit platform authority keeps authenticated reads excluded from server rendering. + * + * @access private + * @since unreleased + * + * @type {boolean} + */ + private readonly browser: boolean = isPlatformBrowser(inject(PLATFORM_ID)); + /** + * Property view + * @readonly + * + * @description + * Native tabs preserve source order and do not recreate the command store. + * + * @access protected + * @since unreleased + * + * @type {Signal<'suppliers' | 'orders' | 'receipts'>} + */ + protected readonly view: Signal<'suppliers' | 'orders' | 'receipts'> = computed(() => + this.section() === 'suppliers' + ? 'suppliers' + : this.section() === 'receipts' + ? 'receipts' + : 'orders', + ); + /** + * Property editor + * @readonly + * + * @description + * Current editor's native form. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal<'supplier' | 'order' | 'receipt' | 'return' | null>} + */ + protected readonly editor: WritableSignal<'supplier' | 'order' | 'receipt' | 'return' | null> = + signal(null); + /** + * Property editingSupplier + * @readonly + * + * @description + * Existing supplier snapshot used by optimistic changes. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal} + */ + protected readonly editingSupplier: WritableSignal = signal(null); + /** + * Property editingOrder + * @readonly + * + * @description + * Existing purchase draft snapshot used by optimistic changes. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal} + */ + protected readonly editingOrder: WritableSignal = signal(null); + /** + * Property receivingLine + * @readonly + * + * @description + * Source line for one physical delivery. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal} + */ + protected readonly receivingLine: WritableSignal = signal(null); + /** + * Property returningReceipt + * @readonly + * + * @description + * Source receipt for one physical return. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal} + */ + protected readonly returningReceipt: WritableSignal = + signal(null); + /** + * Property dirty + * @readonly + * + * @description + * Typed form dirtiness is relayed by the currently active editor. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal} + */ + protected readonly dirty: WritableSignal = signal(false); + /** + * Property confirmation + * @readonly + * + * @description + * Consequential operation awaits an explicit native confirmation. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal} + */ + protected readonly confirmation: WritableSignal = signal(null); + /** + * Property reviewRequested + * @readonly + * + * @description + * A revision is not adoptable until the reader explicitly requested its server refresh. + * + * @access private + * @since unreleased + * + * @type {WritableSignal} + */ + private readonly reviewRequested: WritableSignal = signal(false); + /** + * Property supplierContextId + * @readonly + * + * @description + * Primitive source identity prevents repeated supplier fetches for unchanged order revisions. + * + * @access private + * @since unreleased + * + * @type {Signal} + */ + private readonly supplierContextId: Signal = computed(() => + this.editor() === 'order' + ? (this.editingOrder()?.supplierId ?? null) + : (this.store.selectedOrder()?.supplierId ?? null), + ); + /** + * Property discardState + * @readonly + * + * @description + * Controlled editor or route dismissal. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal<'open' | 'closed'>} + */ + protected readonly discardState: WritableSignal<'open' | 'closed'> = signal('closed'); + /** + * Property deactivationResolver + * + * @description + * Waiting route guard decision. + * + * @access private + * @since unreleased + * + * @type {((allowed: boolean) => void) | null} + */ + private deactivationResolver: ((allowed: boolean) => void) | null = null; + /** + * Property catalogueOrganizationId + * + * @description + * Prevents a successful scope's secondary catalogue from refetching on every list refresh. + * + * @access private + * @since unreleased + * + * @type {string | null} + */ + private catalogueOrganizationId: string | null = null; + /** + * Property displayOrganizationId + * + * @description + * Identity whose editor drafts have been reset on an organization switch. + * + * @access private + * @since unreleased + * + * @type {string | null} + */ + private displayOrganizationId: string | null = null; + /** + * Property supplierSearch + * @readonly + * + * @description + * Supplier server-search draft. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal} + */ + protected readonly supplierSearch: WritableSignal = signal(''); + /** + * Property supplierPage + * @readonly + * + * @description + * Supplier directory server page. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal} + */ + protected readonly supplierPage: WritableSignal = signal(1); + /** + * Property supplierFilter + * @readonly + * + * @description + * Supplier archive filter; archival history remains readable. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal} + */ + protected readonly supplierFilter: WritableSignal = signal('active'); + /** + * Property orderPage + * @readonly + * + * @description + * Purchase server page. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal} + */ + protected readonly orderPage: WritableSignal = signal(1); + /** + * Property orderStatus + * @readonly + * + * @description + * Purchase lifecycle server filter. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal} + */ + protected readonly orderStatus: WritableSignal = signal('all'); + /** + * Property receiptPage + * @readonly + * + * @description + * Receipt server page. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal} + */ + protected readonly receiptPage: WritableSignal = signal(1); + /** + * Property returnPage + * @readonly + * + * @description + * Physical return history server page. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal} + */ + protected readonly returnPage: WritableSignal = signal(1); + /** + * Property returnsReceiptId + * @readonly + * + * @description + * Receipt whose motivated return history is expanded. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal} + */ + protected readonly returnsReceiptId: WritableSignal = signal(null); + /** + * Property pickerSearch + * @readonly + * + * @description + * Order form supplier server-search draft. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal} + */ + protected readonly pickerSearch: WritableSignal = signal(''); + /** + * Property pickerPage + * @readonly + * + * @description + * Order form supplier server page. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal} + */ + protected readonly pickerPage: WritableSignal = signal(1); + /** + * Property canManage + * @readonly + * + * @description + * Explicit procurement management permission. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly canManage: Signal = computed(() => + this.permissions.hasPermission(ORGANIZATION_PERMISSION.PROCUREMENT_MANAGE), + ); + /** + * Property canManageStock + * @readonly + * + * @description + * Stock receipt and reversal additionally require Inventory's actual manage permission. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly canManageStock: Signal = computed(() => + this.permissions.hasPermission(ORGANIZATION_PERMISSION.INVENTORY_MANAGE), + ); + /** + * Property canCreateEquipment + * @readonly + * + * @description + * Reserve equipment creation additionally requires Equipment's actual write permission. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly canCreateEquipment: Signal = computed(() => + this.permissions.hasPermission(ORGANIZATION_PERMISSION.EQUIPMENT_WRITE), + ); + /** + * Property canReadCosts + * @readonly + * + * @description + * Cost visibility is independent from procurement read and write rights. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly canReadCosts: Signal = computed(() => + this.permissions.hasPermission(ORGANIZATION_PERMISSION.MAINTENANCE_COST_READ), + ); + /** + * Property canEditCosts + * @readonly + * + * @description + * Editing known costs requires both independent financial capabilities. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly canEditCosts: Signal = computed( + () => + this.canReadCosts() && + this.permissions.hasPermission(ORGANIZATION_PERMISSION.MAINTENANCE_COST_MANAGE), + ); + /** + * Property locked + * @readonly + * + * @description + * One uncertain physical declaration must be recovered before a conflicting new command. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly locked: Signal = computed( + () => this.store.commandPending() || this.store.uncertainCommand(), + ); + /** + * Property commandError + * @readonly + * + * @description + * Recoverable normalized error, never a raw HTTP exception. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly commandError: Signal = computed( + () => this.store.commandCallState().error?.message ?? null, + ); + /** + * Property revisionConflict + * @readonly + * + * @description + * Revision rejection exposes explicit refresh without silently replacing any typed draft. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly revisionConflict: Signal = computed(() => + [409, 412].includes(Number(this.store.commandCallState().error?.code)), + ); + /** + * Property supplierPageCount + * @readonly + * + * @description + * Server page counts are presentation only, never client aggregation. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly supplierPageCount: Signal = computed(() => + Math.max(1, Math.ceil(this.store.totalSuppliers() / 30)), + ); + /** + * Property orderPageCount + * @readonly + * + * @description + * Server purchase page count. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly orderPageCount: Signal = computed(() => + Math.max(1, Math.ceil(this.store.totalOrders() / 30)), + ); + /** + * Property receiptPageCount + * @readonly + * + * @description + * Server receipt page count. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly receiptPageCount: Signal = computed(() => + Math.max(1, Math.ceil(this.store.totalReceipts() / 30)), + ); + /** + * Property returnPageCount + * @readonly + * + * @description + * Server retained return page count. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly returnPageCount: Signal = computed(() => + Math.max(1, Math.ceil(this.store.totalReturns() / 30)), + ); + /** + * Property statusLabel + * @readonly + * + * @description + * Friendly status registry, preserving new server values as readable fallback labels. + * + * @access protected + * @since unreleased + * + * @type {typeof procurementStatusLabel} + */ + protected readonly statusLabel: typeof procurementStatusLabel = procurementStatusLabel; + /** + * Property supplierFilters + * @readonly + * + * @description + * Native supplier archival choices. + * + * @access protected + * @since unreleased + * + * @type {readonly { value: string; label: string }[]} + */ + protected readonly supplierFilters: readonly { value: string; label: string }[] = [ + { value: 'active', label: $localize`:@@procurement.suppliers.active:Active suppliers` }, + { value: 'archived', label: $localize`:@@procurement.suppliers.archived:Archived suppliers` }, + { value: 'all', label: $localize`:@@procurement.suppliers.all:All suppliers` }, + ]; + /** + * Property orderStatuses + * @readonly + * + * @description + * Native purchase lifecycle choices delegate filtering to the API. + * + * @access protected + * @since unreleased + * + * @type {readonly { value: string; label: string }[]} + */ + protected readonly orderStatuses: readonly { value: string; label: string }[] = [ + { value: 'all', label: $localize`:@@procurement.orders.all:All orders` }, + ...['draft', 'ordered', 'partial_received', 'received', 'cancelled'].map((value) => ({ + value, + label: procurementStatusLabel('order', value), + })), + ]; + /** + * Property supplierFilterLabelOf + * @readonly + * + * @description + * Native select label resolver. + * + * @access protected + * @since unreleased + * + * @type {(value: string) => string} + */ + protected readonly supplierFilterLabelOf: (value: string) => string = (value) => + this.supplierFilters.find((option) => option.value === value)?.label ?? value; + /** + * Property orderStatusLabelOf + * @readonly + * + * @description + * Native select purchase label resolver. + * + * @access protected + * @since unreleased + * + * @type {(value: string) => string} + */ + protected readonly orderStatusLabelOf: (value: string) => string = (value) => + this.orderStatuses.find((option) => option.value === value)?.label ?? value; + /** + * Property editorTitle + * @readonly + * + * @description + * Descriptive editor title, retaining named native sheet semantics. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly editorTitle: Signal = computed(() => { + switch (this.editor()) { + case 'supplier': + return $localize`:@@procurement.editor.supplier:Supplier information`; + case 'order': + return $localize`:@@procurement.editor.order:Purchase draft`; + case 'receipt': + return $localize`:@@procurement.editor.receipt:Record a physical delivery`; + case 'return': + return $localize`:@@procurement.editor.return:Record a physical return`; + default: + return $localize`:@@procurement.editor.title:Procurement`; + } + }); + /** + * Property confirmationTitle + * @readonly + * + * @description + * Clear operation-specific commitment title. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly confirmationTitle: Signal = computed(() => { + switch (this.confirmation()?.kind) { + case 'archive_supplier': + return $localize`:@@procurement.confirm.archiveTitle:Archive this supplier?`; + case 'place_order': + return $localize`:@@procurement.confirm.placeTitle:Place this purchase order?`; + case 'cancel_remaining': + return $localize`:@@procurement.confirm.cancelTitle:Cancel undelivered quantities?`; + case 'individualize': + return $localize`:@@procurement.confirm.unitsTitle:Create reserve equipment for this receipt?`; + case 'reconcile': + return $localize`:@@procurement.confirm.reconcileTitle:Reconcile this physical return?`; + default: + return $localize`:@@procurement.confirm.title:Confirm operation`; + } + }); + /** + * Property confirmationDescription + * @readonly + * + * @description + * Explicit effect of the confirmed action. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly confirmationDescription: Signal = computed(() => { + switch (this.confirmation()?.kind) { + case 'archive_supplier': + return $localize`:@@procurement.confirm.archiveHint:The supplier remains in existing purchases and history. New orders require an active supplier.`; + case 'place_order': + return $localize`:@@procurement.confirm.placeHint:The draft becomes an order. Physical deliveries will be recorded separately.`; + case 'cancel_remaining': + return $localize`:@@procurement.confirm.cancelHint:Only quantities still awaiting delivery are cancelled. Previous receipts and returns remain retained.`; + case 'individualize': + return $localize`:@@procurement.confirm.unitsHint:The server creates a bounded set of individually identified reserve equipment. A quota or unavailable type keeps the physical receipt awaiting action.`; + case 'reconcile': + return $localize`:@@procurement.confirm.reconcileHint:The server tries to confirm the inventory reversal. The original physical quantity and reason stay unchanged if stock is still insufficient.`; + default: + return ''; + } + }); + + /** + * Constructor + * @constructor + * + * @description + * Starts browser-only scoped reads and responds only to confirmed same-organization writes. + * + * @access public + * @since unreleased + */ + public constructor() { + afterNextRender(() => { + if (this.browser) this.browserReady.set(true); + }); + effect(() => { + if (!this.browserReady()) return; + const org = this.organizationId(); + untracked(() => { + this.store.setScope(org); + if (this.displayOrganizationId !== org) { + this.displayOrganizationId = org; + this.editor.set(null); + this.confirmation.set(null); + this.dirty.set(false); + this.returnsReceiptId.set(null); + this.catalog.clear(); + this.catalogueOrganizationId = null; + } + }); + }); + effect(() => { + if (!this.browserReady()) return; + const org = this.organizationId(); + const view = this.view(); + const page = this.supplierPage(); + const search = this.supplierSearch(); + const archived = this.supplierFilter(); + if (view !== 'suppliers') return; + untracked(() => + this.store.loadSuppliers({ + organizationId: org, + options: { + page, + itemsPerPage: 30, + search, + params: { archived: archived === 'all' ? 'all' : archived === 'archived' }, + }, + }), + ); + }); + effect(() => { + if (!this.browserReady()) return; + const org = this.organizationId(); + const view = this.view(); + const page = this.orderPage(); + const status = this.orderStatus(); + if (view === 'suppliers') return; + untracked(() => + this.store.loadOrders({ + organizationId: org, + options: { page, itemsPerPage: 30, params: status === 'all' ? {} : { status } }, + }), + ); + }); + effect(() => { + if (!this.browserReady()) return; + const org = this.organizationId(); + const orderId = this.orderId(); + untracked(() => { + if (orderId) this.store.readOrder({ organizationId: org, orderId }); + else this.store.clearOrder(); + }); + }); + effect(() => { + if (!this.browserReady() || this.view() !== 'receipts' || !this.orderId()) return; + const org = this.organizationId(); + const orderId = this.orderId(); + const page = this.receiptPage(); + if (orderId) + untracked(() => + this.store.loadReceipts({ + organizationId: org, + orderId, + options: { page, itemsPerPage: 30 }, + }), + ); + }); + effect(() => { + if (!this.browserReady() || !this.returnsReceiptId()) return; + const org = this.organizationId(); + const receiptId = this.returnsReceiptId(); + const page = this.returnPage(); + if (receiptId) + untracked(() => + this.store.loadReturns({ + organizationId: org, + receiptId, + options: { page, itemsPerPage: 30 }, + }), + ); + }); + effect(() => { + if (!this.browserReady() || this.editor() !== 'order') return; + const org = this.organizationId(); + const search = this.pickerSearch(); + const page = this.pickerPage(); + untracked(() => + this.store.loadSuppliers({ + organizationId: org, + options: { page, itemsPerPage: 30, search, params: { archived: false } }, + }), + ); + }); + effect(() => { + if (!this.browserReady()) return; + const supplierId = this.supplierContextId(); + const org = this.organizationId(); + if (supplierId) untracked(() => this.store.readSupplier({ organizationId: org, supplierId })); + }); + effect(() => { + if (!this.browserReady()) return; + const org = this.organizationId(); + const needed = this.store + .selectedOrder() + ?.lines.some((line) => line.kind === 'equipment_to_individualize'); + if (needed && this.catalogueOrganizationId !== org) { + this.catalogueOrganizationId = org; + untracked(() => this.catalog.load(org)); + } + }); + this.events + .on(procurementStoreEvents.saved) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(({ payload }) => { + if (payload.organizationId !== this.organizationId()) return; + this.editor.set(null); + this.confirmation.set(null); + this.dirty.set(false); + if (payload.command.kind === 'create_order' || payload.command.kind === 'update_order') { + void this.router.navigate([], { + queryParams: { orderId: payload.result.id, section: 'orders' }, + queryParamsHandling: 'merge', + }); + } + this.reload(); + }); + } + + /** + * Method selectSection + * @method selectSection + * + * @description + * Native tabs keep URL and physical source context coherent. + * + * @access protected + * @since unreleased + * + * @param {string} section - Displayed resource or choice for the procurement workflow. + * + * @returns {void} No return value; updates or emits the explicit workflow intent. + */ + protected selectSection(section: string): void { + if (this.locked()) return; + void this.router.navigate([], { queryParams: { section }, queryParamsHandling: 'merge' }); + } + /** + * Method ensureEquipmentTypes + * @method ensureEquipmentTypes + * + * @description + * Loads the Equipment catalogue only when its native picker or retained material lines need it. + * + * @access protected + * @since unreleased + * + * @returns {void} No return value; updates or emits the explicit workflow intent. + */ + protected ensureEquipmentTypes(): void { + const org = this.organizationId(); + if (this.catalogueOrganizationId === org) return; + this.catalogueOrganizationId = org; + this.catalog.load(org); + } + /** + * Method selectOrder + * @method selectOrder + * + * @description + * Selects a server order without relying on the current collection page as authority. + * + * @access protected + * @since unreleased + * + * @param {PurchaseOrderOutput} order - Displayed resource or choice for the procurement workflow. + * @param {boolean} receipts - Displayed resource or choice for the procurement workflow. + * + * @returns {void} No return value; updates or emits the explicit workflow intent. + */ + protected selectOrder(order: PurchaseOrderOutput, receipts: boolean = false): void { + if (this.locked()) return; + this.receiptPage.set(1); + this.returnsReceiptId.set(null); + void this.router.navigate([], { + queryParams: { orderId: order.id, section: receipts ? 'receipts' : 'orders' }, + queryParamsHandling: 'merge', + }); + } + /** + * Method clearSelection + * @method clearSelection + * + * @description + * Clears only source context; retained records remain on the server. + * + * @access protected + * @since unreleased + * + * @returns {void} No return value; updates or emits the explicit workflow intent. + */ + protected clearSelection(): void { + if (this.locked()) return; + void this.router.navigate([], { queryParams: { orderId: null }, queryParamsHandling: 'merge' }); + } + /** + * Method openSupplier + * @method openSupplier + * + * @description + * Opens an authorized supplier editor with the displayed revision. + * + * @access protected + * @since unreleased + * + * @param {SupplierOutput | null} supplier - Displayed resource or choice for the procurement + * workflow. + * + * @returns {void} No return value; updates or emits the explicit workflow intent. + */ + protected openSupplier(supplier: SupplierOutput | null): void { + if (!this.canManage() || this.locked()) return; + this.store.clearCommand(); + this.reviewRequested.set(false); + this.editingSupplier.set(supplier); + this.dirty.set(false); + this.editor.set('supplier'); + } + /** + * Method openOrder + * @method openOrder + * + * @description + * Opens an authorized purchase draft editor without publishing or placing it. + * + * @access protected + * @since unreleased + * + * @param {PurchaseOrderOutput | null} order - Displayed resource or choice for the procurement + * workflow. + * + * @returns {void} No return value; updates or emits the explicit workflow intent. + */ + protected openOrder(order: PurchaseOrderOutput | null): void { + if (!this.canManage() || this.locked() || (order && order.status !== 'draft')) return; + this.store.clearCommand(); + this.reviewRequested.set(false); + this.editingOrder.set(order); + this.pickerPage.set(1); + this.pickerSearch.set(''); + this.dirty.set(false); + this.editor.set('order'); + } + /** + * Method saveSupplier + * @method saveSupplier + * + * @description + * Emits the draft supplier change while retaining its original revision snapshot. + * + * @access protected + * @since unreleased + * + * @param {ChangeSupplierInput} commandInput - Validated native form payload, retaining its exact + * strings. + * + * @returns {void} No return value; updates or emits the explicit workflow intent. + */ + protected saveSupplier(commandInput: ChangeSupplierInput): void { + if (!this.canManage() || this.locked()) return; + const supplier = this.editingSupplier(); + this.store.execute( + supplier + ? { + kind: 'update_supplier', + organizationId: this.organizationId(), + supplier, + input: commandInput, + } + : { kind: 'create_supplier', organizationId: this.organizationId(), input: commandInput }, + ); + } + /** + * Method saveOrder + * @method saveOrder + * + * @description + * Saves exact purchase input; the server alone permits draft transitions. + * + * @access protected + * @since unreleased + * + * @param {ChangePurchaseOrderInput} commandInput - Validated native form payload, retaining its + * exact strings. + * + * @returns {void} No return value; updates or emits the explicit workflow intent. + */ + protected saveOrder(commandInput: ChangePurchaseOrderInput): void { + if (!this.canManage() || this.locked()) return; + const order = this.editingOrder(); + this.store.execute( + order + ? { + kind: 'update_order', + organizationId: this.organizationId(), + order, + input: commandInput, + } + : { kind: 'create_order', organizationId: this.organizationId(), input: commandInput }, + ); + } + /** + * Method canEditOrder + * @method canEditOrder + * + * @description + * Native draft actions are available only after its displayed source has finished loading. + * + * @access protected + * @since unreleased + * + * @param {PurchaseOrderOutput} order - Displayed resource or choice for the procurement workflow. + * + * @returns {boolean} The presentation or navigation decision for the current server source. + */ + protected canEditOrder(order: PurchaseOrderOutput): boolean { + return ( + this.canManage() && + order.status === 'draft' && + this.store.orderCallState().status === 'success' + ); + } + /** + * Method canCancelOrder + * @method canCancelOrder + * + * @description + * The server permits cancellation only while an order still has undelivered quantities. + * + * @access protected + * @since unreleased + * + * @param {PurchaseOrderOutput} order - Displayed resource or choice for the procurement workflow. + * + * @returns {boolean} The presentation or navigation decision for the current server source. + */ + protected canCancelOrder(order: PurchaseOrderOutput): boolean { + return ( + this.canManage() && + order.status !== 'received' && + order.status !== 'cancelled' && + this.store.orderCallState().status === 'success' + ); + } + /** + * Method canIndividualize + * @method canIndividualize + * + * @description + * Individualization remains a separate explicit operation with Equipment write capability. + * + * @access protected + * @since unreleased + * + * @param {ProcurementReceiptOutput} receipt - Displayed resource or choice for the procurement + * workflow. + * + * @returns {boolean} The presentation or navigation decision for the current server source. + */ + protected canIndividualize(receipt: ProcurementReceiptOutput): boolean { + return ( + this.canManage() && + this.canCreateEquipment() && + receipt.status === 'awaiting_individualization' && + !receipt.equipmentIds.length && + this.store.receiptsCallState().status === 'success' + ); + } + /** + * Method canReconcile + * @method canReconcile + * + * @description + * Retained physical returns require a separate Inventory management operation to reconcile. + * + * @access protected + * @since unreleased + * + * @param {ProcurementReturnOutput} returned - Displayed resource or choice for the procurement + * workflow. + * + * @returns {boolean} The presentation or navigation decision for the current server source. + */ + protected canReconcile(returned: ProcurementReturnOutput): boolean { + return ( + this.canManage() && + this.canManageStock() && + returned.status === 'awaiting_reconciliation' && + this.store.returnsCallState().status === 'success' + ); + } + /** + * Method canReceive + * @method canReceive + * + * @description + * Distinguishes stock versus reserve type authorization for one physical source line. + * + * @access protected + * @since unreleased + * + * @param {PurchaseOrderOutput} order - Displayed resource or choice for the procurement workflow. + * @param {PurchaseOrderLineOutput} line - Displayed resource or choice for the procurement + * workflow. + * + * @returns {boolean} The presentation or navigation decision for the current server source. + */ + protected canReceive(order: PurchaseOrderOutput, line: PurchaseOrderLineOutput): boolean { + return ( + this.canManage() && + order.id === this.orderId() && + this.store.orderCallState().status === 'success' && + (order.status === 'ordered' || order.status === 'partial_received') && + (exactDecimalUnits(line.remainingQuantity) ?? 0n) > 0n && + (line.kind !== 'part' || this.canManageStock()) + ); + } + /** + * Method openReceipt + * @method openReceipt + * + * @description + * Opens one bounded physical delivery; it never implies reserve equipment creation. + * + * @access protected + * @since unreleased + * + * @param {PurchaseOrderOutput} order - Displayed resource or choice for the procurement workflow. + * @param {PurchaseOrderLineOutput} line - Displayed resource or choice for the procurement + * workflow. + * + * @returns {void} No return value; updates or emits the explicit workflow intent. + */ + protected openReceipt(order: PurchaseOrderOutput, line: PurchaseOrderLineOutput): void { + if (!this.canReceive(order, line) || this.locked()) return; + this.store.clearCommand(); + this.reviewRequested.set(false); + this.editingOrder.set(order); + this.receivingLine.set(line); + this.dirty.set(false); + this.editor.set('receipt'); + } + /** + * Method recordReceipt + * @method recordReceipt + * + * @description + * Sends one physical delivery with its original displayed source order revision. + * + * @access protected + * @since unreleased + * + * @param {ReceivePurchaseOrderInput} commandInput - Validated native form payload, retaining its + * exact strings. + * + * @returns {void} No return value; updates or emits the explicit workflow intent. + */ + protected recordReceipt(commandInput: ReceivePurchaseOrderInput): void { + const order = this.editingOrder(); + const line = this.receivingLine(); + if (!order || !line || !this.canReceive(order, line) || this.locked()) return; + this.store.execute({ + kind: 'receive', + organizationId: this.organizationId(), + order, + input: commandInput, + }); + } + /** + * Method canReturn + * @method canReturn + * + * @description + * Original physical quantities and Equipment lifecycle bound the return action. + * + * @access protected + * @since unreleased + * + * @param {ProcurementReceiptOutput} receipt - Displayed resource or choice for the procurement + * workflow. + * + * @returns {boolean} The presentation or navigation decision for the current server source. + */ + protected canReturn(receipt: ProcurementReceiptOutput): boolean { + return ( + this.canManage() && + !receipt.equipmentIds.length && + (exactDecimalUnits(exactDecimalDifference(receipt.quantity, receipt.returnedQuantity)) ?? + 0n) > 0n && + (receipt.kind !== 'part' || this.canManageStock()) + ); + } + /** + * Method openReturn + * @method openReturn + * + * @description + * Opens a motivated return linked to a retained source. + * + * @access protected + * @since unreleased + * + * @param {ProcurementReceiptOutput} receipt - Displayed resource or choice for the procurement + * workflow. + * + * @returns {void} No return value; updates or emits the explicit workflow intent. + */ + protected openReturn(receipt: ProcurementReceiptOutput): void { + if (!this.canReturn(receipt) || this.locked()) return; + this.store.clearCommand(); + this.reviewRequested.set(false); + this.returningReceipt.set(receipt); + this.dirty.set(false); + this.editor.set('return'); + } + /** + * Method recordReturn + * @method recordReturn + * + * @description + * Records the physical fact even when stock will need later reconciliation. + * + * @access protected + * @since unreleased + * + * @param {ReturnProcurementReceiptInput} commandInput - Validated native form payload, retaining + * its exact strings. + * + * @returns {void} No return value; updates or emits the explicit workflow intent. + */ + protected recordReturn(commandInput: ReturnProcurementReceiptInput): void { + const receipt = this.returningReceipt(); + if (!receipt || !this.canReturn(receipt) || this.locked()) return; + this.store.execute({ + kind: 'return', + organizationId: this.organizationId(), + receipt, + input: commandInput, + }); + } + /** + * Method requestConfirmation + * @method requestConfirmation + * + * @description + * Consequential operations always carry displayed revisions into explicit confirmation. + * + * @access protected + * @since unreleased + * + * @param {ProcurementCommand} command - Displayed server resource and the explicit proposed + * operation. + * + * @returns {void} No return value; updates or emits the explicit workflow intent. + */ + protected requestConfirmation(command: ProcurementCommand): void { + if (this.locked() || !this.canManage()) return; + if (command.kind === 'individualize' && !this.canCreateEquipment()) return; + if (command.kind === 'reconcile' && !this.canManageStock()) return; + this.store.clearCommand(); + this.reviewRequested.set(false); + this.confirmation.set(command); + } + /** + * Method requestIndividualization + * @method requestIndividualization + * + * @description + * A fresh individualization attempt is distinct from transport retry of a previous UUID. + * + * @access protected + * @since unreleased + * + * @param {ProcurementReceiptOutput} receipt - Displayed resource or choice for the procurement + * workflow. + * + * @returns {void} No return value; updates or emits the explicit workflow intent. + */ + protected requestIndividualization(receipt: ProcurementReceiptOutput): void { + this.requestConfirmation({ + kind: 'individualize', + organizationId: this.organizationId(), + receipt, + input: { clientOperationId: globalThis.crypto.randomUUID() }, + }); + } + /** + * Method requestReconciliation + * @method requestReconciliation + * + * @description + * A fresh reconciliation attempt keeps the original physical return declaration immutable. + * + * @access protected + * @since unreleased + * + * @param {ProcurementReturnOutput} returned - Displayed resource or choice for the procurement + * workflow. + * + * @returns {void} No return value; updates or emits the explicit workflow intent. + */ + protected requestReconciliation(returned: ProcurementReturnOutput): void { + this.requestConfirmation({ + kind: 'reconcile', + organizationId: this.organizationId(), + returned, + input: { clientOperationId: globalThis.crypto.randomUUID() }, + }); + } + /** + * Method confirmOperation + * @method confirmOperation + * + * @description + * Sends only the operation the reader reviewed in the native confirmation. + * + * @access protected + * @since unreleased + * + * @returns {void} No return value; updates or emits the explicit workflow intent. + */ + protected confirmOperation(): void { + const command = this.confirmation(); + if (command && !this.locked()) this.store.execute(command); + } + /** + * Method retryCommand + * @method retryCommand + * + * @description + * Replays the retained original scope, revision and physical payload verbatim. + * + * @access protected + * @since unreleased + * + * @returns {void} No return value; updates or emits the explicit workflow intent. + */ + protected retryCommand(): void { + const command = this.store.command(); + if (command && !this.store.commandPending()) this.store.execute(command); + } + /** + * Method reviewLatest + * @method reviewLatest + * + * @description + * Refreshes displayed revisions explicitly while the forms keep their current draft identity. + * + * @access protected + * @since unreleased + * + * @returns {void} No return value; updates or emits the explicit workflow intent. + */ + protected reviewLatest(): void { + if (this.locked()) return; + this.reviewRequested.set(true); + const command = this.confirmation(); + const supplier = + this.editingSupplier() ?? (command?.kind === 'archive_supplier' ? command.supplier : null); + const order = + this.editingOrder() ?? + (command?.kind === 'place_order' || command?.kind === 'cancel_remaining' + ? command.order + : this.store.selectedOrder()); + this.reload(); + if (supplier) + this.store.readSupplier({ organizationId: this.organizationId(), supplierId: supplier.id }); + if (order && order.id !== this.orderId()) + this.store.readOrder({ organizationId: this.organizationId(), orderId: order.id }); + const receipt = + this.returningReceipt() ?? (command?.kind === 'individualize' ? command.receipt : null); + if (receipt) + this.store.readReceipt({ organizationId: this.organizationId(), receiptId: receipt.id }); + if (command?.kind === 'reconcile') + this.store.readReturn({ + organizationId: this.organizationId(), + returnId: command.returned.id, + }); + } + /** + * Method reviewReady + * @method reviewReady + * + * @description + * A newly reviewed source must have finished successfully before adopting its revision. + * + * @access protected + * @since unreleased + * + * @returns {boolean} The presentation or navigation decision for the current server source. + */ + protected reviewReady(): boolean { + if (!this.reviewRequested() || this.locked()) return false; + const command = this.confirmation(); + if (this.editor() === 'supplier' || command?.kind === 'archive_supplier') { + const id = + this.editingSupplier()?.id ?? + (command?.kind === 'archive_supplier' ? command.supplier.id : null); + return ( + this.store.supplierCallState().status === 'success' && + this.store.supplierCallState().data?.id === id + ); + } + if ( + this.editor() === 'order' || + this.editor() === 'receipt' || + command?.kind === 'place_order' || + command?.kind === 'cancel_remaining' + ) { + const id = + this.editingOrder()?.id ?? + (command?.kind === 'place_order' || command?.kind === 'cancel_remaining' + ? command.order.id + : null); + return ( + this.store.orderCallState().status === 'success' && this.store.selectedOrder()?.id === id + ); + } + if (this.editor() === 'return' || command?.kind === 'individualize') { + const id = + this.returningReceipt()?.id ?? + (command?.kind === 'individualize' ? command.receipt.id : null); + return ( + this.store.receiptCallState().status === 'success' && + this.store.receiptCallState().data?.id === id + ); + } + if (command?.kind === 'reconcile') + return ( + this.store.returnCallState().status === 'success' && + this.store.returnCallState().data?.id === command.returned.id + ); + return false; + } + /** + * Method adoptLatestRevision + * @method adoptLatestRevision + * + * @description + * Latest resource snapshots become authority only after the reader chose review. + * + * @access protected + * @since unreleased + * + * @returns {void} No return value; updates or emits the explicit workflow intent. + */ + protected adoptLatestRevision(): void { + if (!this.reviewReady()) return; + const supplier = this.store.supplierCallState().data; + const order = this.store.selectedOrder(); + if (this.editor() === 'supplier' && supplier?.id === this.editingSupplier()?.id) + this.editingSupplier.set(supplier); + if (this.editor() === 'order' && order?.id === this.editingOrder()?.id) + this.editingOrder.set(order); + const line = this.receivingLine(); + if (this.editor() === 'receipt' && line && order) { + const current = order.lines.find((item) => item.id === line.id); + if (current) { + this.receivingLine.set(current); + this.editingOrder.set(order); + } + } + const receipt = this.returningReceipt(); + if (this.editor() === 'return' && receipt) { + const current = this.store.receiptCallState().data; + if (current) this.returningReceipt.set(current); + } + const command = this.confirmation(); + if (command?.kind === 'archive_supplier' && supplier) + this.confirmation.set({ ...command, supplier }); + if ((command?.kind === 'place_order' || command?.kind === 'cancel_remaining') && order) + this.confirmation.set({ ...command, order }); + if (command?.kind === 'individualize' && this.store.receiptCallState().data) { + const current = this.store.receiptCallState().data; + if (current) this.confirmation.set({ ...command, receipt: current }); + } + if (command?.kind === 'reconcile' && this.store.returnCallState().data) { + const current = this.store.returnCallState().data; + if (current) this.confirmation.set({ ...command, returned: current }); + } + this.reviewRequested.set(false); + this.store.clearCommand(); + } + /** + * Method reload + * @method reload + * + * @description + * Reloads the active server view and physical source without deriving local quantities. + * + * @access protected + * @since unreleased + * + * @returns {void} No return value; updates or emits the explicit workflow intent. + */ + protected reload(): void { + const org = this.organizationId(); + if (this.view() === 'suppliers') + this.store.loadSuppliers({ + organizationId: org, + options: { + page: this.supplierPage(), + itemsPerPage: 30, + search: this.supplierSearch(), + params: { + archived: + this.supplierFilter() === 'all' ? 'all' : this.supplierFilter() === 'archived', + }, + }, + }); + else + this.store.loadOrders({ + organizationId: org, + options: { + page: this.orderPage(), + itemsPerPage: 30, + params: this.orderStatus() === 'all' ? {} : { status: this.orderStatus() }, + }, + }); + const orderId = this.orderId(); + if (orderId) { + this.store.readOrder({ organizationId: org, orderId }); + if (this.view() === 'receipts') + this.store.loadReceipts({ + organizationId: org, + orderId, + options: { page: this.receiptPage(), itemsPerPage: 30 }, + }); + } + const receiptId = this.returnsReceiptId(); + if (receiptId) + this.store.loadReturns({ + organizationId: org, + receiptId, + options: { page: this.returnPage(), itemsPerPage: 30 }, + }); + } + /** + * Method showReturns + * @method showReturns + * + * @description + * Reads return history only when the reader opens the retained receipt's own section. + * + * @access protected + * @since unreleased + * + * @param {ProcurementReceiptOutput} receipt - Displayed resource or choice for the procurement + * workflow. + * + * @returns {void} No return value; updates or emits the explicit workflow intent. + */ + protected showReturns(receipt: ProcurementReceiptOutput): void { + this.returnPage.set(1); + this.returnsReceiptId.set(this.returnsReceiptId() === receipt.id ? null : receipt.id); + } + /** + * Method lineTitle + * @method lineTitle + * + * @description + * Current catalogue name or preserved fallback, never an invented equipment family. + * + * @access protected + * @since unreleased + * + * @param {PurchaseOrderLineOutput} line - Displayed resource or choice for the procurement + * workflow. + * + * @returns {string} The presentation or navigation decision for the current server source. + */ + protected lineTitle(line: PurchaseOrderLineOutput): string { + if (line.kind === 'part') { + return line.partLabel + ? line.partCode + ? `${line.partCode} — ${line.partLabel}` + : line.partLabel + : $localize`:@@procurement.line.articleUnavailable:Stock article label unavailable`; + } + const type = + this.catalog.options().find((option) => option.value === line.typeCode)?.label ?? + line.typeCode ?? + ''; + const identity = line.identityTemplate; + const name = + 'name' in identity && typeof identity['name'] === 'string' ? identity['name'] : null; + return name ? `${name} · ${type}` : type; + } + /** + * Method lineUnit + * @method lineUnit + * + * @description + * Never substitutes an assumed unit when a historical stock reference cannot be read. + * + * @access protected + * @since unreleased + * + * @param {PurchaseOrderLineOutput} line - Displayed resource or choice for the procurement + * workflow. + * + * @returns {string} The presentation or navigation decision for the current server source. + */ + protected lineUnit(line: PurchaseOrderLineOutput): string { + if (line.kind !== 'part') return $localize`:@@procurement.line.units:units`; + return line.partUnit + ? line.partUnit + : $localize`:@@procurement.line.unitUnavailable:unit unavailable`; + } + /** + * Method receiptUnit + * @method receiptUnit + * + * @description + * Physical receipt inherits its source line's unit, not an unrelated global default. + * + * @access protected + * @since unreleased + * + * @param {ProcurementReceiptOutput} receipt - Displayed resource or choice for the procurement + * workflow. + * + * @returns {string} The presentation or navigation decision for the current server source. + */ + protected receiptUnit(receipt: ProcurementReceiptOutput): string { + const line = this.store.selectedOrder()?.lines.find((item) => item.id === receipt.lineId); + return line ? this.lineUnit(line) : ''; + } + /** + * Method blockedLabel + * @method blockedLabel + * + * @description + * Friendly blocking reasons preserve the server decision without claiming completed stock. + * + * @access protected + * @since unreleased + * + * @param {string} reason - Displayed resource or choice for the procurement workflow. + * + * @returns {string} The presentation or navigation decision for the current server source. + */ + protected blockedLabel(reason: string): string { + const labels: Readonly> = { + quota_exceeded: $localize`:@@procurement.blocked.quota:Reserve equipment could not be created because the organization quota was reached.`, + unavailable_type: $localize`:@@procurement.blocked.type:The equipment type is currently unavailable. The physical receipt is retained.`, + insufficient_stock: $localize`:@@procurement.blocked.stock:The physical return is retained. Inventory needs reconciliation before its movement can be confirmed.`, + }; + return labels[reason] ?? reason.replaceAll('_', ' '); + } + /** + * Method requestClose + * @method requestClose + * + * @description + * Locks dismissal during any accepted or transport-uncertain physical operation. + * + * @access protected + * @since unreleased + * + * @returns {void} No return value; updates or emits the explicit workflow intent. + */ + protected requestClose(): void { + if (this.locked()) return; + if (this.dirty()) this.discardState.set('open'); + else this.editor.set(null); + } + /** + * Method sheetStateChanged + * @method sheetStateChanged + * + * @description + * Native sheet close follows the same controlled draft decision. + * + * @access protected + * @since unreleased + * + * @param {string} state - Displayed resource or choice for the procurement workflow. + * + * @returns {void} No return value; updates or emits the explicit workflow intent. + */ + protected sheetStateChanged(state: string): void { + if (state === 'closed' && this.editor()) this.requestClose(); + } + /** + * Method confirmationStateChanged + * @method confirmationStateChanged + * + * @description + * Native operation dialog never dismisses an accepted or uncertain command. + * + * @access protected + * @since unreleased + * + * @param {string} state - Displayed resource or choice for the procurement workflow. + * + * @returns {void} No return value; updates or emits the explicit workflow intent. + */ + protected confirmationStateChanged(state: string): void { + if (state === 'closed' && !this.locked()) this.confirmation.set(null); + } + /** + * Method hasUnsavedChanges + * @method hasUnsavedChanges + * + * @description + * Typed drafts and unresolved facts participate in guarded navigation. + * + * @access public + * @since unreleased + * + * @returns {boolean} The presentation or navigation decision for the current server source. + */ + public hasUnsavedChanges(): boolean { + return this.dirty() || this.locked(); + } + /** + * Method confirmDeactivation + * @method confirmDeactivation + * + * @description + * Accepted or uncertain physical writes require result recovery before leaving the workspace. + * + * @access public + * @since unreleased + * + * @returns {Promise} The presentation or navigation decision for the current server + * source. + */ + public confirmDeactivation(): Promise { + if (this.locked()) return Promise.resolve(false); + this.discardState.set('open'); + return new Promise((resolve) => { + this.deactivationResolver = resolve; + }); + } + /** + * Method resolveDiscard + * @method resolveDiscard + * + * @description + * Resolves controlled editor and navigation dismissal exactly once. + * + * @access protected + * @since unreleased + * + * @param {boolean} allowed - Whether the reader confirmed discarding the local draft. + * + * @returns {void} No return value; updates or emits the explicit workflow intent. + */ + protected resolveDiscard(allowed: boolean): void { + this.discardState.set('closed'); + if (allowed) { + this.dirty.set(false); + this.editor.set(null); + } + this.deactivationResolver?.(allowed); + this.deactivationResolver = null; + } + /** + * Method beforeUnload + * @method beforeUnload + * + * @description + * Browser close keeps the standard native warning for drafts or unresolved physical results. + * + * @access protected + * @since unreleased + * + * @param {BeforeUnloadEvent} event - Native browser event. + * + * @returns {void} No return value; updates or emits the explicit workflow intent. + */ + protected beforeUnload(event: BeforeUnloadEvent): void { + if (this.hasUnsavedChanges()) { + event.preventDefault(); + event.returnValue = ''; + } + } +} diff --git a/src/app/features/organization/features/procurement/ui/pages/procurement-page/testing/procurement-page.component.spec.ts b/src/app/features/organization/features/procurement/ui/pages/procurement-page/testing/procurement-page.component.spec.ts new file mode 100644 index 000000000..f6d4dbc2a --- /dev/null +++ b/src/app/features/organization/features/procurement/ui/pages/procurement-page/testing/procurement-page.component.spec.ts @@ -0,0 +1,495 @@ +import { PLATFORM_ID, signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { Router } from '@angular/router'; +import { Events } from '@ngrx/signals/events'; +import { Subject } from 'rxjs'; +import { + idleCallState, + pendingCallState, + successCallState, + type CallState, +} from '@core/request-state'; +import { OrganizationPermissionService } from '@features/organization/access'; +import { EquipmentTypeCatalogStore } from '@features/organization/features/equipments'; +import type { + ProcurementReceiptOutput, + ProcurementReturnOutput, + PurchaseOrderLineOutput, + PurchaseOrderOutput, + ReceivePurchaseOrderInput, + SupplierOutput, +} from '@features/organization/features/procurement/models'; +import { + ProcurementStore, + procurementStoreEvents, +} from '@features/organization/features/procurement/state'; +import type { + ProcurementCommand, + ProcurementMutationOutput, +} from '@features/organization/features/procurement/state/procurement/models/procurement-command.type'; +import { ORGANIZATION_PERMISSION } from '@features/organization/models'; +import { REGIONAL_FORMATTING_PORT } from '@features/organization/ports'; +import { ProcurementPage } from '../procurement-page.component'; + +describe('ProcurementPage', () => { + const stockLine: PurchaseOrderLineOutput = { + id: 'line', + kind: 'part', + partId: 'part', + quantity: '1.000000', + receivedQuantity: '0.250000', + returnedQuantity: '0.000000', + remainingQuantity: '0.750000', + identityTemplate: {}, + partCode: 'SEAL', + partLabel: 'Seal', + partUnit: 'piece', + }; + const order: PurchaseOrderOutput = { + '@id': '/orders/order', + '@type': 'PurchaseOrder', + id: 'order', + organizationId: 'org', + supplierId: 'supplier', + name: 'Fire supplies', + currency: 'EUR', + status: 'ordered', + lines: [stockLine], + financialVisible: false, + revision: 7, + createdAt: '2026-10-05T10:00:00Z', + updatedAt: '2026-10-05T10:00:00Z', + replayed: false, + }; + const receipt: ProcurementReceiptOutput = { + '@id': '/receipts/receipt', + '@type': 'ProcurementReceipt', + id: 'receipt', + organizationId: 'org', + orderId: 'order', + lineId: 'line', + kind: 'part', + quantity: '0.250000', + returnedQuantity: '0.050000', + pendingReturnQuantity: '0.050000', + warehouseId: 'warehouse', + currency: 'EUR', + receivedAt: '2026-10-05T10:00:00Z', + createdAt: '2026-10-05T10:00:00Z', + equipmentIds: [], + status: 'stock_received', + revision: 3, + financialVisible: false, + replayed: false, + }; + const returned: ProcurementReturnOutput = { + '@id': '/returns/returned', + '@type': 'ProcurementReturn', + id: 'returned', + organizationId: 'org', + receiptId: 'receipt', + clientOperationId: '1ce5e2b5-dbbb-4b41-a1a0-c681df34e143', + quantity: '0.050000', + reason: 'Damaged packaging', + status: 'awaiting_reconciliation', + createdAt: '2026-10-05T10:00:00Z', + revision: 4, + replayed: false, + }; + const supplier: SupplierOutput = { + '@id': '/suppliers/supplier', + '@type': 'Supplier', + id: 'supplier', + organizationId: 'org', + name: 'Fire supplies', + contacts: [], + revision: 2, + createdAt: '2026-10-05T10:00:00Z', + updatedAt: '2026-10-05T10:00:00Z', + replayed: false, + }; + const receiptInput: ReceivePurchaseOrderInput = { + lineId: 'line', + warehouseId: 'warehouse', + quantity: '0.250000', + receivedAt: '2026-10-05T12:00:00+02:00', + clientOperationId: 'fd8ef21f-cc2f-4bc5-a3c3-d958188dd72a', + }; + + async function setup( + initialPermissions: readonly string[] = [ + ORGANIZATION_PERMISSION.PROCUREMENT_MANAGE, + ORGANIZATION_PERMISSION.INVENTORY_MANAGE, + ORGANIZATION_PERMISSION.EQUIPMENT_WRITE, + ], + platformId: 'browser' | 'server' = 'browser', + ) { + const permissions = signal>(new Set(initialPermissions)); + const store = { + setScope: vi.fn(), + clearOrder: vi.fn(), + clearCommand: vi.fn(), + loadSuppliers: vi.fn(), + loadOrders: vi.fn(), + loadReceipts: vi.fn(), + loadReturns: vi.fn(), + readSupplier: vi.fn(), + readOrder: vi.fn(), + readReceipt: vi.fn(), + readReturn: vi.fn(), + execute: vi.fn(), + commandPending: signal(false), + uncertainCommand: signal(false), + command: signal(null), + commandCallState: signal>(idleCallState()), + orderCallState: signal>(successCallState(order)), + supplierCallState: signal>(idleCallState()), + receiptCallState: signal>(idleCallState()), + returnCallState: signal>(idleCallState()), + selectedOrder: signal(order), + receiptsCallState: signal(successCallState(null)), + returnsCallState: signal(successCallState(null)), + receiptEntityMap: signal>>({}), + supplyReturnEntityMap: signal>>({}), + totalSuppliers: signal(0), + totalOrders: signal(0), + totalReceipts: signal(0), + totalReturns: signal(0), + }; + const catalog = { load: vi.fn(), clear: vi.fn(), options: signal([]) }; + const navigate = vi.fn().mockResolvedValue(true); + const savedEvents = new Subject>(); + TestBed.configureTestingModule({ + imports: [ProcurementPage], + providers: [ + { provide: PLATFORM_ID, useValue: platformId }, + { provide: ProcurementStore, useValue: store }, + { provide: Router, useValue: { navigate } }, + { + provide: OrganizationPermissionService, + useValue: { hasPermission: (permission: string) => permissions().has(permission) }, + }, + { + provide: REGIONAL_FORMATTING_PORT, + useValue: { regionalFormatting: signal({ dateFormat: 'yyyy-MM-dd', timezone: 'UTC' }) }, + }, + { provide: Events, useValue: { on: () => savedEvents } }, + ], + }).overrideComponent(ProcurementPage, { + set: { template: '', providers: [{ provide: EquipmentTypeCatalogStore, useValue: catalog }] }, + }); + const fixture = TestBed.createComponent(ProcurementPage); + fixture.componentRef.setInput('organizationId', 'org'); + fixture.componentRef.setInput('section', 'receipts'); + fixture.componentRef.setInput('orderId', 'order'); + fixture.detectChanges(); + await fixture.whenStable(); + return { + fixture, + page: fixture.componentInstance, + store, + catalog, + permissions, + navigate, + savedEvents, + }; + } + + it('does not load authenticated procurement or catalogue data while rendered on the server', async () => { + const { store, catalog } = await setup([ORGANIZATION_PERMISSION.PROCUREMENT_MANAGE], 'server'); + expect(store.setScope).not.toHaveBeenCalled(); + expect(store.loadOrders).not.toHaveBeenCalled(); + expect(store.readOrder).not.toHaveBeenCalled(); + expect(store.loadReceipts).not.toHaveBeenCalled(); + expect(store.loadSuppliers).not.toHaveBeenCalled(); + expect(store.readSupplier).not.toHaveBeenCalled(); + expect(store.loadReturns).not.toHaveBeenCalled(); + expect(catalog.load).not.toHaveBeenCalled(); + }); + + it('sends all suppliers explicitly and preserves that scope on refresh', async () => { + const { fixture, page, store } = await setup([ORGANIZATION_PERMISSION.PROCUREMENT_READ]); + fixture.componentRef.setInput('section', 'suppliers'); + fixture.componentRef.setInput('orderId', undefined); + await fixture.whenStable(); + page['supplierFilter'].set('all'); + await fixture.whenStable(); + expect(store.loadSuppliers).toHaveBeenLastCalledWith({ + organizationId: 'org', + options: { page: 1, itemsPerPage: 30, search: '', params: { archived: 'all' } }, + }); + page['reload'](); + expect(store.loadSuppliers).toHaveBeenLastCalledWith({ + organizationId: 'org', + options: { page: 1, itemsPerPage: 30, search: '', params: { archived: 'all' } }, + }); + page['supplierFilter'].set('archived'); + await fixture.whenStable(); + expect(store.loadSuppliers.mock.lastCall?.[0].options.params).toEqual({ archived: true }); + }); + + it('forwards the route organization and physical source to browser-only server reads', async () => { + const { store, catalog } = await setup(); + expect(store.setScope).toHaveBeenCalledWith('org'); + expect(store.readOrder).toHaveBeenCalledWith({ organizationId: 'org', orderId: 'order' }); + expect(store.loadReceipts).toHaveBeenCalledWith({ + organizationId: 'org', + orderId: 'order', + options: { page: 1, itemsPerPage: 30 }, + }); + expect(store.loadReturns).not.toHaveBeenCalled(); + expect(catalog.load).not.toHaveBeenCalled(); + }); + + it('requires independent financial read and manage permissions before editing costs', async () => { + const { page, permissions } = await setup([ORGANIZATION_PERMISSION.PROCUREMENT_MANAGE]); + expect(page['canReadCosts']()).toBe(false); + expect(page['canEditCosts']()).toBe(false); + permissions.set(new Set([ORGANIZATION_PERMISSION.MAINTENANCE_COST_MANAGE])); + expect(page['canReadCosts']()).toBe(false); + expect(page['canEditCosts']()).toBe(false); + permissions.set(new Set([ORGANIZATION_PERMISSION.MAINTENANCE_COST_READ])); + expect(page['canReadCosts']()).toBe(true); + expect(page['canEditCosts']()).toBe(false); + permissions.set( + new Set([ + ORGANIZATION_PERMISSION.MAINTENANCE_COST_READ, + ORGANIZATION_PERMISSION.MAINTENANCE_COST_MANAGE, + ]), + ); + expect(page['canEditCosts']()).toBe(true); + expect(page['canManage']()).toBe(false); + }); + + it('requires Inventory management for stock receipts and Equipment write for explicit individualization', async () => { + const { page, permissions } = await setup([ORGANIZATION_PERMISSION.PROCUREMENT_MANAGE]); + const hardwareLine: PurchaseOrderLineOutput = { + ...stockLine, + kind: 'equipment_to_individualize', + partId: null, + typeCode: 'EXTINGUISHER', + }; + const hardwareReceipt: ProcurementReceiptOutput = { + ...receipt, + kind: 'equipment_to_individualize', + status: 'awaiting_individualization', + }; + expect(page['canReceive'](order, stockLine)).toBe(false); + expect(page['canReceive'](order, hardwareLine)).toBe(true); + expect(page['canIndividualize'](hardwareReceipt)).toBe(false); + permissions.set( + new Set([ + ORGANIZATION_PERMISSION.PROCUREMENT_MANAGE, + ORGANIZATION_PERMISSION.EQUIPMENT_WRITE, + ]), + ); + expect(page['canIndividualize'](hardwareReceipt)).toBe(true); + permissions.set( + new Set([ + ORGANIZATION_PERMISSION.PROCUREMENT_MANAGE, + ORGANIZATION_PERMISSION.INVENTORY_MANAGE, + ]), + ); + expect(page['canReceive'](order, stockLine)).toBe(true); + expect(page['canIndividualize'](hardwareReceipt)).toBe(false); + expect(page['canReconcile'](returned)).toBe(true); + }); + + it('rejects unavailable or exhausted physical sources while the displayed authority is loading', async () => { + const { page, store } = await setup(); + expect(page['canReceive']({ ...order, id: 'another' }, stockLine)).toBe(false); + expect(page['canReceive'](order, { ...stockLine, remainingQuantity: '0.000000' })).toBe(false); + expect(page['canReceive']({ ...order, status: 'cancelled' }, stockLine)).toBe(false); + store.orderCallState.set(pendingCallState(order)); + expect(page['canReceive'](order, stockLine)).toBe(false); + expect(page['canEditOrder']({ ...order, status: 'draft' })).toBe(false); + expect(page['canCancelOrder'](order)).toBe(false); + page['openReceipt'](order, stockLine); + expect(page['editor']()).toBeNull(); + expect(store.execute).not.toHaveBeenCalled(); + }); + + it.each(['pending', 'uncertain'])( + 'locks navigation and dismissal during a %s operation', + async (state) => { + const { page, store, navigate } = await setup(); + page['openSupplier'](supplier); + if (state === 'pending') store.commandPending.set(true); + else store.uncertainCommand.set(true); + page['requestClose'](); + page['selectSection']('orders'); + page['openReceipt'](order, stockLine); + expect(page['editor']()).toBe('supplier'); + expect(navigate).not.toHaveBeenCalled(); + expect(page.hasUnsavedChanges()).toBe(true); + await expect(page.confirmDeactivation()).resolves.toBe(false); + }, + ); + + it('sends the displayed original order revision until the reader explicitly reviews a newer source', async () => { + const { page, store } = await setup(); + page['openReceipt'](order, stockLine); + store.selectedOrder.set({ ...order, revision: 8 }); + store.orderCallState.set(successCallState({ ...order, revision: 8 })); + page['recordReceipt'](receiptInput); + expect(store.execute).toHaveBeenCalledExactlyOnceWith({ + kind: 'receive', + organizationId: 'org', + order, + input: receiptInput, + }); + expect(page['receivingLine']()).toEqual(stockLine); + }); + + it('adopts a source revision only after explicit review finishes while retaining the physical draft', async () => { + const { page, store } = await setup(); + const latest = { + ...order, + revision: 8, + lines: [{ ...stockLine, remainingQuantity: '0.500000' }], + }; + page['openReceipt'](order, stockLine); + page['dirty'].set(true); + store.selectedOrder.set(latest); + page['adoptLatestRevision'](); + expect(page['editingOrder']()).toEqual(order); + page['reviewLatest'](); + store.orderCallState.set(pendingCallState(order)); + expect(page['reviewReady']()).toBe(false); + page['adoptLatestRevision'](); + expect(page['editingOrder']()).toEqual(order); + store.orderCallState.set(successCallState(latest)); + expect(page['reviewReady']()).toBe(true); + page['adoptLatestRevision'](); + expect(page['editingOrder']()).toEqual(latest); + expect(page['receivingLine']()).toEqual(latest.lines[0]); + expect(page['dirty']()).toBe(true); + page['recordReceipt'](receiptInput); + expect(store.execute).toHaveBeenLastCalledWith({ + kind: 'receive', + organizationId: 'org', + order: latest, + input: receiptInput, + }); + }); + + it('reviews a reconciliation source directly and preserves the attempt UUID while adopting its revision', async () => { + const { page, store } = await setup(); + page['requestReconciliation'](returned); + const original = page['confirmation'](); + page['reviewLatest'](); + expect(store.readReturn).toHaveBeenCalledExactlyOnceWith({ + organizationId: 'org', + returnId: 'returned', + }); + store.returnCallState.set(pendingCallState(returned)); + expect(page['reviewReady']()).toBe(false); + store.returnCallState.set(successCallState({ ...returned, id: 'unrelated-return' })); + expect(page['reviewReady']()).toBe(false); + const latest = { ...returned, revision: 5 }; + store.returnCallState.set(successCallState(latest)); + expect(page['reviewReady']()).toBe(true); + page['adoptLatestRevision'](); + expect(page['confirmation']()).toEqual({ ...original, returned: latest }); + expect(page['confirmation']()).toMatchObject({ + returned: { + quantity: returned.quantity, + reason: returned.reason, + clientOperationId: returned.clientOperationId, + }, + }); + expect(store.clearCommand).toHaveBeenCalled(); + }); + + it('keeps returned physical quantities authoritative and excludes individualized equipment returns', async () => { + const { page, permissions } = await setup(); + expect(page['canReturn'](receipt)).toBe(true); + expect(page['canReturn']({ ...receipt, returnedQuantity: receipt.quantity })).toBe(false); + expect(page['canReturn']({ ...receipt, equipmentIds: ['equipment'] })).toBe(false); + permissions.set(new Set([ORGANIZATION_PERMISSION.PROCUREMENT_MANAGE])); + expect(page['canReturn'](receipt)).toBe(false); + }); + + it('requires confirmation for a reconciliation and keeps transport retry on the retained UUID', async () => { + const { page, store } = await setup(); + page['requestReconciliation'](returned); + const command = page['confirmation'](); + expect(command).toMatchObject({ kind: 'reconcile', organizationId: 'org', returned }); + expect(store.execute).not.toHaveBeenCalled(); + page['confirmOperation'](); + expect(store.execute).toHaveBeenCalledExactlyOnceWith(command); + store.command.set(command); + store.uncertainCommand.set(true); + page['retryCommand'](); + expect(store.execute).toHaveBeenLastCalledWith(command); + store.commandPending.set(true); + page['retryCommand'](); + expect(store.execute).toHaveBeenCalledTimes(2); + }); + + it('loads return history only for the explicitly opened physical receipt', async () => { + const { page, store, fixture } = await setup(); + page['returnPage'].set(3); + page['showReturns'](receipt); + await fixture.whenStable(); + expect(store.loadReturns).toHaveBeenCalledExactlyOnceWith({ + organizationId: 'org', + receiptId: 'receipt', + options: { page: 1, itemsPerPage: 30 }, + }); + expect(page.orderId()).toBe('order'); + page['showReturns'](receipt); + await fixture.whenStable(); + expect(page['returnsReceiptId']()).toBeNull(); + expect(store.loadReturns).toHaveBeenCalledOnce(); + }); + + it('keeps a dirty editor after foreign events and closes only after a confirmed same-organization result', async () => { + const { page, savedEvents } = await setup(); + page['openSupplier'](supplier); + page['dirty'].set(true); + savedEvents.next( + procurementStoreEvents.saved({ + organizationId: 'another', + command: { + kind: 'create_supplier', + organizationId: 'another', + input: { name: 'Foreign', contacts: [] }, + }, + result: { ...supplier, organizationId: 'another' }, + }), + ); + expect(page['editor']()).toBe('supplier'); + expect(page.hasUnsavedChanges()).toBe(true); + savedEvents.next( + procurementStoreEvents.saved({ + organizationId: 'org', + command: { + kind: 'update_supplier', + organizationId: 'org', + supplier, + input: { name: 'Reviewed', contacts: [] }, + }, + result: supplier, + }), + ); + expect(page['editor']()).toBeNull(); + expect(page.hasUnsavedChanges()).toBe(false); + }); + + it('preserves a declined discard and resolves explicit navigation confirmation once', async () => { + const { page } = await setup(); + page['openSupplier'](supplier); + page['dirty'].set(true); + const declined = page.confirmDeactivation(); + page['resolveDiscard'](false); + await expect(declined).resolves.toBe(false); + expect(page['editor']()).toBe('supplier'); + expect(page.hasUnsavedChanges()).toBe(true); + const accepted = page.confirmDeactivation(); + page['resolveDiscard'](true); + await expect(accepted).resolves.toBe(true); + expect(page['editor']()).toBeNull(); + expect(page.hasUnsavedChanges()).toBe(false); + }); +}); diff --git a/src/app/features/organization/features/procurement/utils/exact-decimal/exact-decimal.utils.ts b/src/app/features/organization/features/procurement/utils/exact-decimal/exact-decimal.utils.ts new file mode 100644 index 000000000..e8dce0f7f --- /dev/null +++ b/src/app/features/organization/features/procurement/utils/exact-decimal/exact-decimal.utils.ts @@ -0,0 +1,58 @@ +/** + * Function canonicalExactDecimal + * + * @description + * Normalizes user-entered decimals textually to the six-digit transport format, without floats. + * + * @access public + * @since unreleased + * + * @param {string} value - Ungrouped nonnegative decimal text with at most six fractional digits. + * + * @returns {string | null} Canonical exact decimal or null when the input is invalid. + */ +export function canonicalExactDecimal(value: string): string | null { + const match: RegExpMatchArray | null = /^(\d+)(?:\.(\d{1,6}))?$/.exec(value.trim()); + if (!match) return null; + return `${BigInt(match[1]).toString()}.${(match[2] ?? '').padEnd(6, '0')}`; +} + +/** + * Function exactDecimalUnits + * + * @description + * Converts a validated decimal into integer millionths for local input bounds, never valuation. + * + * @access public + * @since unreleased + * + * @param {string} value - Exact decimal text. + * + * @returns {bigint | null} Millionths or null for invalid input. + */ +export function exactDecimalUnits(value: string): bigint | null { + const canonical: string | null = canonicalExactDecimal(value); + return canonical ? BigInt(canonical.replace('.', '')) : null; +} + +/** + * Function exactDecimalDifference + * + * @description + * Gives a local return-form bound from one retained receipt, without mutating server quantities. + * + * @access public + * @since unreleased + * + * @param {string} value - Gross received decimal quantity. + * @param {string} removed - Already returned decimal quantity. + * + * @returns {string} Remaining exact local bound; invalid server values disable the form with zero. + */ +export function exactDecimalDifference(value: string, removed: string): string { + const first: bigint | null = exactDecimalUnits(value); + const second: bigint | null = exactDecimalUnits(removed); + if (first === null || second === null || second > first) return '0.000000'; + const difference: bigint = first - second; + return `${difference / 1000000n}.${(difference % 1000000n).toString().padStart(6, '0')}`; +} diff --git a/src/app/features/organization/features/procurement/utils/exact-decimal/testing/exact-decimal.utils.spec.ts b/src/app/features/organization/features/procurement/utils/exact-decimal/testing/exact-decimal.utils.spec.ts new file mode 100644 index 000000000..86ed139e4 --- /dev/null +++ b/src/app/features/organization/features/procurement/utils/exact-decimal/testing/exact-decimal.utils.spec.ts @@ -0,0 +1,21 @@ +import { isProcurementQuantity } from '@features/organization/features/procurement/validators'; +import { canonicalExactDecimal, exactDecimalDifference } from '../exact-decimal.utils'; + +describe('Exact procurement decimal inputs', () => { + it('retains millionths and large amounts without floating-point rounding', () => { + expect(canonicalExactDecimal('000.250000')).toBe('0.250000'); + expect(canonicalExactDecimal('999999999999.123456')).toBe('999999999999.123456'); + expect(exactDecimalDifference('0.300000', '0.100000')).toBe('0.200000'); + }); + it.each(['NaN', '1e3', '0.0000001', '1,25', '-0.1', 'Infinity'])( + 'rejects ambiguous or lossy input %s', + (value) => expect(canonicalExactDecimal(value)).toBeNull(), + ); + it('keeps fractional stock quantities but rejects fractional individual units and source overdelivery', () => { + expect(isProcurementQuantity('0.25', 'part', '0.300000')).toBe(true); + expect(isProcurementQuantity('0.25', 'equipment_to_individualize')).toBe(false); + expect(isProcurementQuantity('0.300001', 'part', '0.300000')).toBe(false); + expect(isProcurementQuantity('100000.000001', 'part')).toBe(false); + expect(isProcurementQuantity('0', 'part')).toBe(false); + }); +}); diff --git a/src/app/features/organization/features/procurement/utils/index.ts b/src/app/features/organization/features/procurement/utils/index.ts new file mode 100644 index 000000000..0e57feff8 --- /dev/null +++ b/src/app/features/organization/features/procurement/utils/index.ts @@ -0,0 +1,5 @@ +export { + canonicalExactDecimal, + exactDecimalUnits, + exactDecimalDifference, +} from './exact-decimal/exact-decimal.utils'; diff --git a/src/app/features/organization/features/procurement/validators/index.ts b/src/app/features/organization/features/procurement/validators/index.ts new file mode 100644 index 000000000..cd9bcbb73 --- /dev/null +++ b/src/app/features/organization/features/procurement/validators/index.ts @@ -0,0 +1 @@ +export { isProcurementQuantity } from './procurement-quantity/procurement-quantity.validator'; diff --git a/src/app/features/organization/features/procurement/validators/procurement-quantity/procurement-quantity.validator.ts b/src/app/features/organization/features/procurement/validators/procurement-quantity/procurement-quantity.validator.ts new file mode 100644 index 000000000..32e7797f8 --- /dev/null +++ b/src/app/features/organization/features/procurement/validators/procurement-quantity/procurement-quantity.validator.ts @@ -0,0 +1,33 @@ +import type { ProcurementLineKind } from '@features/organization/features/procurement/models'; +import { exactDecimalUnits } from '@features/organization/features/procurement/utils'; +/** + * Function isProcurementQuantity + * + * @description + * Validates local decimal input and individual-unit constraints against an explicit server bound. + * The API remains authoritative for concurrent deliveries and stock. + * + * @access public + * @since unreleased + * + * @param {string} value - User-entered quantity. + * @param {ProcurementLineKind} kind - Stock article or individual equipment. + * @param {string} limit - Exact upper bound from the server or order input contract. + * + * @returns {boolean} Whether the input can be submitted without decimal loss. + */ +export function isProcurementQuantity( + value: string, + kind: ProcurementLineKind, + limit: string = '100000.000000', +): boolean { + const units: bigint | null = exactDecimalUnits(value); + const maximum: bigint | null = exactDecimalUnits(limit); + return ( + units !== null && + maximum !== null && + units > 0n && + units <= maximum && + (kind === 'part' || units % 1000000n === 0n) + ); +} diff --git a/src/app/features/organization/features/service-requests/FEATURE.md b/src/app/features/organization/features/service-requests/FEATURE.md new file mode 100644 index 000000000..c4a8a9bc2 --- /dev/null +++ b/src/app/features/organization/features/service-requests/FEATURE.md @@ -0,0 +1,38 @@ +# Service Requests Feature + +## Purpose + +Owns internal maintenance needs, explicit qualification, motivated decisions and conversion into corrective work. Equipment and Facility retain target ownership; Intervention executes and publishes the resulting work. + +## Entry Points + +`SERVICE_REQUEST_ROUTES` mounts `/organizations/:organizationId/service-requests` and `/:requestId`. The collection guard permits read or create; the dossier guard requires read. Organization supplies the active context and pages keep dedicated mutation affordances. + +Published `models`, `data-access`, `services` and `state` expose request contracts, the owning Hydra transport and conversion coordination. Target pickers are internal form widgets, not a cross-organization directory. + +## State and Data Access + +Component-provided stores cancel obsolete reads. A root conversion coordinator commits its account-bound UUID, request revision and exact work tuple to the independent `fireguard-service-requests` IndexedDB journal before transmission. It keeps accepted transmissions alive after route destruction; returning or reloading restores unresolved conversions without sending them automatically. New actions wait for journal restoration, and storage failures leave them blocked with an explicit retry. + +Auth's published session revision and Organization's active member account, organization and permissions fence asynchronous reads, writes, acknowledgments and exact replay. The root journal purges on logout or account/session replacement even when no request page remains mounted; a same-account browser reload retains its journal. Only conversion has a server-supported replay identity. Other mutations are never journaled or automatically retried. Secondary authenticated reads and all journal access run only in the browser; SSR stores no request command in HTML or TransferState. + +Collections use exact server search, status and equipment/site scopes with pagination. Target pickers separately hydrate retained selections; retired or draft equipment cannot be newly chosen. Request customer identity is derived from the root site by the server and contains no contacts. + +## Cross-Feature Dependencies + +Approved public Equipment transport/models and title/catalogue contracts provide published target choices and authorized open corrective work. Facility transport, models, `toFacilityOption` and `FacilityOptionPicker` provide root sites. Organization owns permissions and regional formatting; Intervention owns the existing destination route and workflow permissions. + +Auth publishes `AUTH_SESSION_PORT` for local session-generation fences. No authentication token is persisted in the command journal. + +## Invariants + +- Request creation, qualification and conversion are distinct commitments. +- Absent optional route targets are normalized before seeding Signal Forms; every target field remains a string. +- A site-only request must identify equipment from its site during qualification. A qualified target cannot be replaced. +- Revision conflicts preserve drafts and require an explicit review before adopting the current revision. +- A conversion retains its original UUID, revision and payload across navigation and reload until confirmed or definitively rejected. A duplicate click never cancels an accepted write. +- An uncertain conversion locks its draft and offers exact retry; progress indicators represent active requests only. +- Route and browser-history dismissal use the sheet's real dirty state. While the local session is established, pending mutations block dismissal; an unresolved durable conversion remains recoverable when its routed page is destroyed. Journal restoration alone never blocks readers from leaving, and session termination always permits authentication redirects. +- New conversion requires request management and intervention planning. Exact receipt replay keeps request management as its grant; the server rechecks planning if new work is still required. +- Known open repair tasks can be explicitly linked through their existing intervention/task identities. No task or intervention identity is fabricated. +- Customer contacts, commercial prices, invoicing, external portals and organization sharing are outside this feature. diff --git a/src/app/features/organization/features/service-requests/data-access/index.ts b/src/app/features/organization/features/service-requests/data-access/index.ts new file mode 100644 index 000000000..5096d4489 --- /dev/null +++ b/src/app/features/organization/features/service-requests/data-access/index.ts @@ -0,0 +1,2 @@ +export { ServiceRequestService } from './services/service-request/service-request.service'; +export { ServiceRequestCommandRepository } from './services/service-request-offline/service-request-command.repository'; diff --git a/src/app/features/organization/features/service-requests/data-access/services/service-request-offline/service-request-command.repository.ts b/src/app/features/organization/features/service-requests/data-access/services/service-request-offline/service-request-command.repository.ts new file mode 100644 index 000000000..703855a9a --- /dev/null +++ b/src/app/features/organization/features/service-requests/data-access/services/service-request-offline/service-request-command.repository.ts @@ -0,0 +1,358 @@ +import { effect, inject, Service } from '@angular/core'; +import { IndexedDbService, type IndexedDbSchema } from '@core/indexed-db'; +import { AUTH_SESSION_PORT, type AuthSessionPort } from '@features/auth/ports'; +import type { ServiceRequestConversionCommand } from '@features/organization/features/service-requests/models'; +import { ORGANIZATION_PERMISSION } from '@features/organization/models'; +import { + ORGANIZATION_MEMBER_ACCESS_PORT, + type OrganizationMemberAccessPort, +} from '@features/organization/ports/organization-member-access'; + +/** + * Class ServiceRequestCommandRepository + * @class ServiceRequestCommandRepository + * + * @description + * Root account-bound journal retains exact conversions across route destruction and browser reload. + */ +@Service() +export class ServiceRequestCommandRepository extends IndexedDbService { + //#region Properties + /** + * Property schema + * @readonly + * + * @description + * Independent database avoids changing other features' offline schemas. + * + * @access protected + * @since unreleased + * + * @type {IndexedDbSchema} + */ + protected readonly schema: IndexedDbSchema = { + name: 'fireguard-service-requests', + version: 1, + storeNames: ['metadata', 'commands'], + ownerStoreName: 'metadata', + }; + + /** + * Property member + * @readonly + * + * @description + * Published active membership supplies account, organization and effective permissions. + * + * @access private + * @since unreleased + * + * @type {OrganizationMemberAccessPort} + */ + private readonly member: OrganizationMemberAccessPort = inject(ORGANIZATION_MEMBER_ACCESS_PORT); + + /** + * Property session + * @readonly + * + * @description + * Session generation fences asynchronous work without persisting tokens. + * + * @access private + * @since unreleased + * + * @type {AuthSessionPort} + */ + private readonly session: AuthSessionPort = inject(AUTH_SESSION_PORT); + + /** + * Property writes + * + * @description + * Serializes durable acceptance, acknowledgment and session purges. + * + * @access private + * @since unreleased + * + * @type {Promise} + */ + private writes: Promise = Promise.resolve(); + + /** + * Property observedRevision + * + * @description + * Last authentication generation synchronously observed by lifecycle and journal operations. + * + * @access private + * @since unreleased + * + * @type {number} + */ + private observedRevision: number = this.session.sessionRevision(); + + /** + * Property observedAuthenticated + * + * @description + * Detects authenticated session loss even before its scheduled effect executes. + * + * @access private + * @since unreleased + * + * @type {boolean} + */ + private observedAuthenticated: boolean = this.session.isAuthenticated(); + + /** + * Property observedUserId + * + * @description + * Retains the last known account across transient organization access loading. + * + * @access private + * @since unreleased + * + * @type {string | null} + */ + private observedUserId: string | null = this.member.profile()?.userId ?? null; + + /** + * Property purgeRequired + * + * @description + * Failed purges remain mandatory before any subsequent private read or accepted conversion. + * + * @access private + * @since unreleased + * + * @type {boolean} + */ + private purgeRequired: boolean = false; + //#endregion + + //#region Constructor + /** + * Constructor + * @constructor + * + * @description + * Keeps lifecycle purges active after a page leaves while preserving a same-account reload + * journal. + * + * @access public + * @since unreleased + */ + public constructor() { + super(); + effect(() => this.observeContext()); + } + //#endregion + + //#region Methods + /** + * Method sessionRevision + * @method sessionRevision + * + * @description + * Captures the local authentication generation before asynchronous work begins. + * + * @access public + * @since unreleased + * + * @returns {number} Current session generation. + */ + public sessionRevision(): number { + this.observeContext(); + return this.session.sessionRevision(); + } + + /** + * Method isCurrent + * @method isCurrent + * + * @description + * Exact replay requires current active membership and request management; planning is checked for + * new work by the caller and the server. + * + * @access public + * @since unreleased + * + * @param {string} userId - Original command account. + * @param {string} organizationId - Original command organization. + * @param {number} revision - Authentication generation captured by the caller. + * + * @returns {boolean} Whether the original command authority still applies. + */ + public isCurrent(userId: string, organizationId: string, revision: number): boolean { + this.observeContext(); + const profile = this.member.profile(); + return ( + this.browser && + this.session.isAuthenticated() && + this.session.sessionRevision() === revision && + !!profile?.isActive && + profile.userId === userId && + profile.organizationId === organizationId && + this.member.permissions().includes(ORGANIZATION_PERMISSION.SERVICE_REQUESTS_MANAGE) + ); + } + + /** + * Method readPending + * @method readPending + * + * @description + * Restores only the active account and organization; reading never transmits a command. + * + * @access public + * @since unreleased + * + * @param {string} userId - Authenticated account. + * @param {string} organizationId - Active organization. + * + * @returns {Promise} Exact unresolved conversions. + */ + public async readPending( + userId: string, + organizationId: string, + ): Promise { + const revision: number = this.sessionRevision(); + if (!this.isCurrent(userId, organizationId, revision)) return []; + await this.writes.catch(() => undefined); + await this.purge(); + if (!this.isCurrent(userId, organizationId, revision)) return []; + await this.ensureOwnerBound(userId); + if (!this.isCurrent(userId, organizationId, revision)) return []; + const commands = await this.getAll('commands'); + return this.isCurrent(userId, organizationId, revision) + ? commands.filter( + (command) => command.userId === userId && command.organizationId === organizationId, + ) + : []; + } + + /** + * Method retain + * @method retain + * + * @description + * Commits the immutable UUID and original body before any network transmission. + * + * @access public + * @since unreleased + * + * @param {ServiceRequestConversionCommand} command - Original accepted conversion. + * @param {number} revision - Authentication generation captured before acceptance. + * + * @returns {Promise} Durable acceptance or an explicit storage/ownership failure. + */ + public retain( + command: ServiceRequestConversionCommand, + revision: number = this.sessionRevision(), + ): Promise { + this.observeContext(); + const current = () => this.isCurrent(command.userId, command.organizationId, revision); + const retained = this.writes + .catch(() => undefined) + .then(async () => { + if (!current()) throw new Error('Maintenance conversion ownership changed.'); + await this.purge(); + if (!current()) throw new Error('Maintenance conversion ownership changed.'); + await this.ensureOwnerBound(command.userId); + if (!current()) throw new Error('Maintenance conversion ownership changed.'); + const id: string = command.input.clientOperationId; + const previous = await this.get('commands', id); + if (!current()) throw new Error('Maintenance conversion ownership changed.'); + if (previous) { + if (JSON.stringify(previous) !== JSON.stringify(command)) + throw new Error( + 'Maintenance conversion UUID already belongs to a different intention.', + ); + return; + } + await this.put('commands', id, command, current); + }); + this.writes = retained.catch(() => undefined); + return retained; + } + + /** + * Method acknowledge + * @method acknowledge + * + * @description + * Removes a confirmed conversion only while its original account authority remains current. + * + * @access public + * @since unreleased + * + * @param {ServiceRequestConversionCommand} command - Confirmed conversion. + * @param {number} revision - Original authentication generation. + * + * @returns {Promise} Completion of the serialized journal update. + */ + public acknowledge(command: ServiceRequestConversionCommand, revision: number): Promise { + this.observeContext(); + const current = () => this.isCurrent(command.userId, command.organizationId, revision); + const acknowledged = this.writes + .catch(() => undefined) + .then(async () => { + if (!current()) return; + await this.purge(); + if (!current()) return; + await this.remove('commands', command.input.clientOperationId, current); + }); + this.writes = acknowledged.catch(() => undefined); + return acknowledged; + } + + /** + * Method observeContext + * @method observeContext + * + * @description + * Marks session replacement synchronously, so a new login cannot race the scheduled purge effect. + * + * @access private + * @since unreleased + * + * @returns {void} Queues the required purge without starting a transmission. + */ + private observeContext(): void { + const revision: number = this.session.sessionRevision(); + const authenticated: boolean = this.session.isAuthenticated(); + const userId: string | null = this.member.profile()?.userId ?? null; + const replaced: boolean = + this.observedRevision !== revision || + (this.observedAuthenticated && !authenticated) || + (this.observedUserId !== null && userId !== null && this.observedUserId !== userId); + this.observedRevision = revision; + this.observedAuthenticated = authenticated; + if (userId !== null) this.observedUserId = userId; + if (!this.browser || !replaced) return; + this.purgeRequired = true; + this.writes = this.writes.catch(() => undefined).then(() => this.purge()); + void this.writes.catch(() => undefined); + } + + /** + * Method purge + * @method purge + * + * @description + * Retries a required owner purge; a failed clear never reopens access to old session commands. + * + * @access private + * @since unreleased + * + * @returns {Promise} Completion of the required purge or its explicit storage error. + */ + private async purge(): Promise { + if (!this.purgeRequired) return; + const revision: number = this.observedRevision; + await this.resetOwnerData(); + if (revision === this.observedRevision) this.purgeRequired = false; + } + //#endregion +} diff --git a/src/app/features/organization/features/service-requests/data-access/services/service-request-offline/testing/service-request-command.repository.spec.ts b/src/app/features/organization/features/service-requests/data-access/services/service-request-offline/testing/service-request-command.repository.spec.ts new file mode 100644 index 000000000..079357c1c --- /dev/null +++ b/src/app/features/organization/features/service-requests/data-access/services/service-request-offline/testing/service-request-command.repository.spec.ts @@ -0,0 +1,203 @@ +import { PLATFORM_ID, signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import type { ServiceRequestConversionCommand } from '@features/organization/features/service-requests/models'; +import { serviceRequestFixture } from '@features/organization/features/service-requests/models/service-request/testing/service-request.fixture'; +import { serviceRequestContext } from '@features/organization/features/service-requests/state/service-request/testing/service-request-context.fixture'; +import { ORGANIZATION_PERMISSION } from '@features/organization/models'; +import { ServiceRequestCommandRepository } from '../service-request-command.repository'; + +const command: ServiceRequestConversionCommand = { + kind: 'convert', + userId: 'user', + organizationId: 'org', + request: serviceRequestFixture({ status: 'qualified', revision: 7 }), + input: { + clientOperationId: 'stable-uuid', + existingInterventionId: 'work', + existingTaskId: 'task', + }, +}; + +function setup( + entries = new Map([['metadata:ownerUserId', command.userId]]), + platform = 'browser', +) { + const grants = signal([ + ORGANIZATION_PERMISSION.SERVICE_REQUESTS_MANAGE, + ORGANIZATION_PERMISSION.INTERVENTIONS_PLAN, + ]); + const context = serviceRequestContext(grants); + TestBed.configureTestingModule({ + providers: [ + ...context.providers, + ServiceRequestCommandRepository, + { provide: PLATFORM_ID, useValue: platform }, + ], + }); + const repository = TestBed.inject(ServiceRequestCommandRepository); + const get = vi + .spyOn(repository, 'get') + .mockImplementation(async (store: string, key: string): Promise => { + const value = entries.get(`${store}:${key}`); + return value === undefined ? null : (structuredClone(value) as T); + }); + const getAll = vi + .spyOn(repository, 'getAll') + .mockImplementation(async (store: string): Promise => + [...entries] + .filter(([key]) => key.startsWith(store + ':')) + .map(([, value]) => structuredClone(value) as T), + ); + const put = vi.spyOn(repository, 'put').mockImplementation(async (store, key, value, current) => { + if (current && !current()) throw new DOMException('Ownership changed', 'AbortError'); + entries.set(`${store}:${key}`, structuredClone(value)); + }); + const remove = vi.spyOn(repository, 'remove').mockImplementation(async (store, key, current) => { + if (current && !current()) throw new DOMException('Ownership changed', 'AbortError'); + entries.delete(`${store}:${key}`); + }); + const clearAll = vi.spyOn(repository, 'clearAll').mockImplementation(async () => { + entries.clear(); + }); + TestBed.tick(); + return { repository, context, grants, entries, get, getAll, put, remove, clearAll }; +} + +describe('ServiceRequestCommandRepository', () => { + it('restores the exact UUID, work tuple and captured revision through a repository reload', async () => { + const first = setup(); + await first.repository.retain(command); + TestBed.resetTestingModule(); + const reloaded = setup(first.entries); + expect(await reloaded.repository.readPending('user', 'org')).toEqual([command]); + expect(reloaded.clearAll).not.toHaveBeenCalled(); + await reloaded.repository.acknowledge(command, 1); + expect(await reloaded.repository.readPending('user', 'org')).toEqual([]); + }); + + it('does not replace an accepted UUID with another revision or work choice', async () => { + const { repository, entries } = setup(); + await repository.retain(command); + await expect( + repository.retain({ ...command, request: { ...command.request, revision: 99 } }), + ).rejects.toThrow('different intention'); + expect(entries.get('commands:stable-uuid')).toEqual(command); + }); + it('reuses an already durable exact conversion without rewriting its journal entry', async () => { + const { repository, put } = setup(); + await repository.retain(command); + put.mockClear(); + put.mockRejectedValue(new DOMException('Quota full', 'QuotaExceededError')); + await repository.retain(structuredClone(command)); + expect(put).not.toHaveBeenCalled(); + expect(await repository.readPending('user', 'org')).toEqual([command]); + }); + + it('actively purges account replacement after route destruction without another journal read', async () => { + const { repository, context, entries, clearAll } = setup(); + await repository.retain(command); + const profile = context.profile(); + if (!profile) throw new Error('Missing fixture member'); + context.profile.set({ ...profile, userId: 'another-user' }); + context.revision.set(2); + TestBed.tick(); + await vi.waitFor(() => expect(clearAll).toHaveBeenCalledOnce()); + expect(entries.size).toBe(0); + expect(repository.isCurrent('user', 'org', 1)).toBe(false); + }); + + it('purges logout even when the same account subsequently returns', async () => { + const { repository, context, entries } = setup(); + await repository.retain(command); + context.authenticated.set(false); + context.revision.set(2); + TestBed.tick(); + await vi.waitFor(() => expect(entries.size).toBe(0)); + context.authenticated.set(true); + context.revision.set(3); + TestBed.tick(); + expect(await repository.readPending('user', 'org')).toEqual([]); + }); + + it('fences an old acceptance while owner binding is still pending', async () => { + const { repository, context, put } = setup(); + let release: () => void = vi.fn(); + const binding = vi.spyOn(repository, 'ensureOwnerBound').mockReturnValue( + new Promise((resolve) => { + release = resolve; + }), + ); + const retention = repository.retain(command); + const rejected = expect(retention).rejects.toThrow('ownership changed'); + await vi.waitFor(() => expect(binding).toHaveBeenCalledOnce()); + context.revision.set(2); + TestBed.tick(); + release(); + await rejected; + expect(put).not.toHaveBeenCalled(); + }); + + it('checks management and original organization while permitting exact replay after planning revocation', async () => { + const { repository, grants, remove } = setup(); + await repository.retain(command); + grants.set([ORGANIZATION_PERMISSION.SERVICE_REQUESTS_MANAGE]); + expect(repository.isCurrent('user', 'org', 1)).toBe(true); + expect(await repository.readPending('user', 'foreign-org')).toEqual([]); + grants.set([]); + expect(repository.isCurrent('user', 'org', 1)).toBe(false); + await repository.acknowledge(command, 1); + expect(remove).not.toHaveBeenCalled(); + }); + + it('purges before a new session reads immediately, without waiting for its lifecycle effect', async () => { + const { repository, context, clearAll } = setup(); + await repository.retain(command); + context.revision.set(2); + expect(await repository.readPending('user', 'org')).toEqual([]); + expect(clearAll).toHaveBeenCalled(); + const fresh = { ...command, input: { ...command.input, clientOperationId: 'new-session' } }; + await repository.retain(fresh, 2); + expect(await repository.readPending('user', 'org')).toEqual([fresh]); + }); + + it('blocks private reads and new commands until a failed session purge succeeds', async () => { + const { repository, context, entries, clearAll, getAll, put } = setup(); + await repository.retain(command); + put.mockClear(); + getAll.mockClear(); + clearAll.mockRejectedValue(new DOMException('Clear blocked', 'AbortError')); + context.revision.set(2); + await expect(repository.readPending('user', 'org')).rejects.toThrow('Clear blocked'); + await expect( + repository.retain({ ...command, input: { clientOperationId: 'fresh' } }, 2), + ).rejects.toThrow('Clear blocked'); + expect(getAll).not.toHaveBeenCalled(); + expect(put).not.toHaveBeenCalled(); + expect(entries.get('commands:stable-uuid')).toEqual(command); + clearAll.mockImplementation(async () => { + entries.clear(); + }); + expect(await repository.readPending('user', 'org')).toEqual([]); + expect(entries.has('commands:stable-uuid')).toBe(false); + }); + + it('propagates storage failure before claiming durable acceptance', async () => { + const { repository, entries, put } = setup(); + put.mockImplementation(async (store, key, value) => { + if (store === 'commands') throw new DOMException('Quota', 'QuotaExceededError'); + entries.set(`${store}:${key}`, value); + }); + await expect(repository.retain(command)).rejects.toMatchObject({ name: 'QuotaExceededError' }); + expect(entries.has('commands:stable-uuid')).toBe(false); + }); + + it('never opens the journal during SSR', async () => { + const { repository, get, getAll, put, clearAll } = setup(undefined, 'server'); + expect(await repository.readPending('user', 'org')).toEqual([]); + await expect(repository.retain(command)).rejects.toThrow('ownership changed'); + expect(get).not.toHaveBeenCalled(); + expect(getAll).not.toHaveBeenCalled(); + expect(put).not.toHaveBeenCalled(); + expect(clearAll).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/features/organization/features/service-requests/data-access/services/service-request/service-request.service.ts b/src/app/features/organization/features/service-requests/data-access/services/service-request/service-request.service.ts new file mode 100644 index 000000000..562d8246a --- /dev/null +++ b/src/app/features/organization/features/service-requests/data-access/services/service-request/service-request.service.ts @@ -0,0 +1,232 @@ +import { Service } from '@angular/core'; +import type { Observable } from 'rxjs'; +import { HydraApiService, type RequestOptions } from '@core/api'; +import type { HydraCollection } from '@core/api/models'; +import type { + ServiceRequestOutput, + CreateServiceRequestInput, + UpdateServiceRequestInput, + QualifyServiceRequestInput, + DecisionServiceRequestInput, + ConvertServiceRequestInput, +} from '@features/organization/features/service-requests/models'; + +/** + * Class ServiceRequestService + * @class ServiceRequestService + * + * @description + * Transport for internal repair requests; workflow decisions remain with the owning server. + * + * @since unreleased + */ +@Service() +export class ServiceRequestService extends HydraApiService { + //#region Methods + + /** + * Method list + * + * @description + * Reads an authorized server page with exact target and workflow filters. + * + * @access public + * @since unreleased + * + * @param {string} organizationId - Owning organization authority. + * @param {RequestOptions} options - Pagination, search and declared scope filters. + * + * @returns {Observable>} Server-confirmed request + * projection. + */ + public list( + organizationId: string, + options?: RequestOptions, + ): Observable> { + return this.getCollection( + `/api/organizations/${organizationId}/service-requests`, + options, + ); + } + + /** + * Method get + * + * @description + * Reads one request and its retained identity. + * + * @access public + * @since unreleased + * + * @param {string} organizationId - Owning organization authority. + * @param {string} requestId - Stable request identity. + * + * @returns {Observable} Server-confirmed request projection. + */ + public get(organizationId: string, requestId: string): Observable { + return this.getOne( + `/api/organizations/${organizationId}/service-requests/${requestId}`, + ); + } + + /** + * Method create + * + * @description + * Creates a described request without converting it into work. + * + * @access public + * @since unreleased + * + * @param {string} organizationId - Owning organization authority. + * @param {CreateServiceRequestInput} input - Validated initial description and target. + * + * @returns {Observable} Server-confirmed request projection. + */ + public create( + organizationId: string, + input: CreateServiceRequestInput, + ): Observable { + return this.post( + `/api/organizations/${organizationId}/service-requests`, + input, + ); + } + + /** + * Method update + * + * @description + * Updates editable fields against the displayed revision. + * + * @access public + * @since unreleased + * + * @param {string} organizationId - Owning organization authority. + * @param {ServiceRequestOutput} request - Displayed immutable request revision. + * @param {UpdateServiceRequestInput} input - Explicit validated command payload. + * + * @returns {Observable} Server-confirmed request projection. + */ + public update( + organizationId: string, + request: ServiceRequestOutput, + input: UpdateServiceRequestInput, + ): Observable { + return this.patch( + `/api/organizations/${organizationId}/service-requests/${request.id}`, + input, + { headers: { 'If-Match': `"revision-${request.revision}"` } }, + ); + } + + /** + * Method qualify + * + * @description + * Explicitly qualifies the request and any site-only equipment choice. + * + * @access public + * @since unreleased + * + * @param {string} organizationId - Owning organization authority. + * @param {ServiceRequestOutput} request - Displayed immutable request revision. + * @param {QualifyServiceRequestInput} input - Explicit validated command payload. + * + * @returns {Observable} Server-confirmed request projection. + */ + public qualify( + organizationId: string, + request: ServiceRequestOutput, + input: QualifyServiceRequestInput, + ): Observable { + return this.post( + `/api/organizations/${organizationId}/service-requests/${request.id}/qualify`, + input, + { headers: { 'If-Match': `"revision-${request.revision}"` } }, + ); + } + + /** + * Method reject + * + * @description + * Rejects the request with a retained reason. + * + * @access public + * @since unreleased + * + * @param {string} organizationId - Owning organization authority. + * @param {ServiceRequestOutput} request - Displayed immutable request revision. + * @param {DecisionServiceRequestInput} input - Explicit validated command payload. + * + * @returns {Observable} Server-confirmed request projection. + */ + public reject( + organizationId: string, + request: ServiceRequestOutput, + input: DecisionServiceRequestInput, + ): Observable { + return this.post( + `/api/organizations/${organizationId}/service-requests/${request.id}/reject`, + input, + { headers: { 'If-Match': `"revision-${request.revision}"` } }, + ); + } + + /** + * Method cancel + * + * @description + * Cancels the request with a retained reason. + * + * @access public + * @since unreleased + * + * @param {string} organizationId - Owning organization authority. + * @param {ServiceRequestOutput} request - Displayed immutable request revision. + * @param {DecisionServiceRequestInput} input - Explicit validated command payload. + * + * @returns {Observable} Server-confirmed request projection. + */ + public cancel( + organizationId: string, + request: ServiceRequestOutput, + input: DecisionServiceRequestInput, + ): Observable { + return this.post( + `/api/organizations/${organizationId}/service-requests/${request.id}/cancel`, + input, + { headers: { 'If-Match': `"revision-${request.revision}"` } }, + ); + } + + /** + * Method convert + * + * @description + * Creates or links corrective work using an unchanged stable conversion identity. + * + * @access public + * @since unreleased + * + * @param {string} organizationId - Owning organization authority. + * @param {ServiceRequestOutput} request - Displayed immutable request revision. + * @param {ConvertServiceRequestInput} input - Explicit validated command payload. + * + * @returns {Observable} Server-confirmed request projection. + */ + public convert( + organizationId: string, + request: ServiceRequestOutput, + input: ConvertServiceRequestInput, + ): Observable { + return this.post( + `/api/organizations/${organizationId}/service-requests/${request.id}/convert`, + input, + { headers: { 'If-Match': `"revision-${request.revision}"` } }, + ); + } + + //#endregion +} diff --git a/src/app/features/organization/features/service-requests/data-access/services/service-request/testing/service-request.service.spec.ts b/src/app/features/organization/features/service-requests/data-access/services/service-request/testing/service-request.service.spec.ts new file mode 100644 index 000000000..6c5d3ae20 --- /dev/null +++ b/src/app/features/organization/features/service-requests/data-access/services/service-request/testing/service-request.service.spec.ts @@ -0,0 +1,116 @@ +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { ENV_CONFIG } from '@core/config/environment/env.token'; +import { serviceRequestFixture } from '@features/organization/features/service-requests/models/service-request/testing/service-request.fixture'; +import { ServiceRequestService } from '../service-request.service'; +describe('ServiceRequestService', () => { + let service: ServiceRequestService, http: HttpTestingController; + const url = 'https://api.test/api/organizations/org/service-requests'; + const request = serviceRequestFixture({ revision: 7 }); + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + ServiceRequestService, + { provide: ENV_CONFIG, useValue: { apiUrl: 'https://api.test' } }, + ], + }); + service = TestBed.inject(ServiceRequestService); + http = TestBed.inject(HttpTestingController); + }); + afterEach(() => http.verify()); + it('keeps server pagination and equipment/site/status search predicates together', () => { + service + .list('org', { + page: 2, + itemsPerPage: 30, + search: 'gauge', + params: { status: 'qualified', equipmentId: 'equipment', siteId: 'site' }, + }) + .subscribe(); + const call = http.expectOne((candidate) => candidate.url === url); + expect(call.request.params.keys()).toEqual( + expect.arrayContaining(['page', 'itemsPerPage', 'search', 'status', 'equipmentId', 'siteId']), + ); + expect(call.request.params.get('page')).toBe('2'); + expect(call.request.params.get('status')).toBe('qualified'); + expect(call.request.params.get('equipmentId')).toBe('equipment'); + expect(call.request.params.get('siteId')).toBe('site'); + call.flush({ member: [request], totalItems: 41 }); + }); + it('creates a described site-only request and keeps source IDs separate from offline client identities', () => { + const input = { + siteId: 'site', + equipmentId: null, + title: 'Gauge damaged', + description: 'Assess the extinguisher gauge', + originInspectionId: 'inspection', + originNonConformityId: 'anomaly', + }; + service.create('org', input).subscribe(); + const call = http.expectOne(url); + expect(call.request.method).toBe('POST'); + expect(call.request.body).toEqual(input); + expect(call.request.body.clientId).toBeUndefined(); + expect(call.request.headers.has('If-Match')).toBe(false); + call.flush(request); + }); + it('updates description only using a quoted optimistic revision', () => { + service.update('org', request, { title: 'Gauge repair', priority: 'high' }).subscribe(); + const call = http.expectOne(url + '/request'); + expect(call.request.method).toBe('PATCH'); + expect(call.request.headers.get('If-Match')).toBe('"revision-7"'); + expect(call.request.headers.get('Content-Type')).toBe('application/merge-patch+json'); + expect(call.request.body).toEqual({ title: 'Gauge repair', priority: 'high' }); + call.flush(request); + }); + it('qualifies a site-only target by explicit equipment selection and preserves stale conflict status', () => { + let failure: unknown; + service + .qualify('org', request, { equipmentId: 'equipment', note: 'Repair required' }) + .subscribe({ error: (error: unknown) => (failure = error) }); + const call = http.expectOne(url + '/request/qualify'); + expect(call.request.headers.get('If-Match')).toBe('"revision-7"'); + expect(call.request.body).toEqual({ equipmentId: 'equipment', note: 'Repair required' }); + call.flush( + { title: 'Revision conflict', detail: 'Review the current request' }, + { status: 412, statusText: 'Precondition Failed' }, + ); + expect(failure).toMatchObject({ status: 412, detail: 'Review the current request' }); + }); + it.each(['reject', 'cancel'] as const)( + 'sends a motivated %s command with the displayed revision', + (action) => { + service[action]('org', request, { reason: 'Not required' }).subscribe(); + const call = http.expectOne(url + '/request/' + action); + expect(call.request.headers.get('If-Match')).toBe('"revision-7"'); + expect(call.request.body).toEqual({ reason: 'Not required' }); + call.flush(request); + }, + ); + it('replays conversion with its stable command and original revision while linking existing real work', () => { + const input = { + clientOperationId: 'operation', + existingInterventionId: 'intervention', + existingTaskId: 'task', + }; + service.convert('org', request, input).subscribe({ error: () => undefined }); + const first = http.expectOne(url + '/request/convert'); + expect(first.request.body).toEqual(input); + first.error(new ProgressEvent('network'), { status: 0 }); + service.convert('org', request, input).subscribe(); + const replay = http.expectOne(url + '/request/convert'); + expect(replay.request.body).toEqual(input); + expect(replay.request.headers.get('If-Match')).toBe('"revision-7"'); + replay.flush( + serviceRequestFixture({ + status: 'converted', + interventionId: 'intervention', + taskId: 'task', + revision: 8, + }), + ); + }); +}); diff --git a/src/app/features/organization/features/service-requests/http/guards/index.ts b/src/app/features/organization/features/service-requests/http/guards/index.ts new file mode 100644 index 000000000..a7e0986d0 --- /dev/null +++ b/src/app/features/organization/features/service-requests/http/guards/index.ts @@ -0,0 +1 @@ +export { serviceRequestDraftLeaveGuard } from './service-request-draft-leave/service-request-draft-leave.guard'; diff --git a/src/app/features/organization/features/service-requests/http/guards/service-request-draft-leave/service-request-draft-leave.guard.ts b/src/app/features/organization/features/service-requests/http/guards/service-request-draft-leave/service-request-draft-leave.guard.ts new file mode 100644 index 000000000..845124a6d --- /dev/null +++ b/src/app/features/organization/features/service-requests/http/guards/service-request-draft-leave/service-request-draft-leave.guard.ts @@ -0,0 +1,18 @@ +import type { CanDeactivateFn } from '@angular/router'; + +/** + * Function serviceRequestDraftLeaveGuard + * + * @description + * Delegates route and browser-history dismissal to the request page's active draft policy. + * + * @access public + * @since unreleased + * + * @param {object} component - Request page that owns the draft and accepted-write decision. + * + * @returns {boolean | Promise} Whether the current request workspace may be destroyed. + */ +export const serviceRequestDraftLeaveGuard: CanDeactivateFn<{ + canLeaveDraft(): boolean | Promise; +}> = (component) => component.canLeaveDraft(); diff --git a/src/app/features/organization/features/service-requests/http/guards/service-request-draft-leave/testing/service-request-draft-leave.guard.spec.ts b/src/app/features/organization/features/service-requests/http/guards/service-request-draft-leave/testing/service-request-draft-leave.guard.spec.ts new file mode 100644 index 000000000..0c0306964 --- /dev/null +++ b/src/app/features/organization/features/service-requests/http/guards/service-request-draft-leave/testing/service-request-draft-leave.guard.spec.ts @@ -0,0 +1,21 @@ +import { serviceRequestDraftLeaveGuard } from '../service-request-draft-leave.guard'; + +describe('serviceRequestDraftLeaveGuard', () => { + it('keeps an accepted write on its current route', () => { + const page = { canLeaveDraft: vi.fn().mockReturnValue(false) }; + expect(serviceRequestDraftLeaveGuard(page, {} as never, {} as never, {} as never)).toBe(false); + expect(page.canLeaveDraft).toHaveBeenCalledOnce(); + }); + + it('waits for the owning native editor discard decision', async () => { + let decide: ((allowed: boolean) => void) | undefined; + const decision = new Promise((resolve) => { + decide = resolve; + }); + const page = { canLeaveDraft: () => decision }; + const navigation = serviceRequestDraftLeaveGuard(page, {} as never, {} as never, {} as never); + expect(navigation).toBe(decision); + decide?.(false); + expect(await navigation).toBe(false); + }); +}); diff --git a/src/app/features/organization/features/service-requests/http/guards/service-request-draft-leave/testing/service-request-draft-leave.navigation.spec.ts b/src/app/features/organization/features/service-requests/http/guards/service-request-draft-leave/testing/service-request-draft-leave.navigation.spec.ts new file mode 100644 index 000000000..be7d80820 --- /dev/null +++ b/src/app/features/organization/features/service-requests/http/guards/service-request-draft-leave/testing/service-request-draft-leave.navigation.spec.ts @@ -0,0 +1,238 @@ +import { ChangeDetectionStrategy, Component, DestroyRef, PLATFORM_ID, signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { provideRouter, Router, withComponentInputBinding } from '@angular/router'; +import { RouterTestingHarness } from '@angular/router/testing'; +import { of, Subject } from 'rxjs'; +import { ConnectivityService } from '@core/connectivity'; +import { FeedbackService } from '@core/feedback'; +import { INTERACTION_CAPABILITIES_PORT } from '@core/interaction-capabilities'; +import { THEME_PORT } from '@core/theme'; +import { OrganizationPermissionService } from '@features/organization/access'; +import { + EquipmentService, + EquipmentTypeService, +} from '@features/organization/features/equipments/data-access'; +import { FacilityService } from '@features/organization/features/facilities/data-access'; +import { ServiceRequestService } from '@features/organization/features/service-requests/data-access'; +import type { ServiceRequestOutput } from '@features/organization/features/service-requests/models'; +import { serviceRequestFixture } from '@features/organization/features/service-requests/models/service-request/testing/service-request.fixture'; +import { SERVICE_REQUEST_ROUTES } from '@features/organization/features/service-requests/service-requests.routes'; +import { ServiceRequestStore } from '@features/organization/features/service-requests/state'; +import { serviceRequestContext } from '@features/organization/features/service-requests/state/service-request/testing/service-request-context.fixture'; +import { ServiceRequestEditorSheet } from '@features/organization/features/service-requests/ui/sheets/service-request-editor-sheet/service-request-editor-sheet.component'; +import { REGIONAL_FORMATTING_PORT } from '@features/organization/ports'; +import { OrganizationMemberAccessStore } from '@features/organization/state'; +import { DEFAULT_REGIONAL_FORMAT_SETTINGS } from '@shared/regional-format'; + +@Component({ template: 'Destination', changeDetection: ChangeDetectionStrategy.OnPush }) +class DestinationPage {} + +describe('serviceRequestDraftLeaveGuard', () => { + beforeAll(() => { + globalThis.ResizeObserver ??= class { + observe(): void {} + unobserve(): void {} + disconnect(): void {} + } as unknown as typeof ResizeObserver; + }); + + async function render( + pageKind: 'directory' | 'detail', + journalState: 'ready' | 'pending' | 'error' = 'ready', + ) { + const request = serviceRequestFixture(); + const context = serviceRequestContext(); + if (journalState === 'pending') + context.journal.readPending.mockImplementation(() => new Promise(() => {})); + if (journalState === 'error') + context.journal.readPending.mockRejectedValue(new Error('Storage unavailable')); + const api = { + list: vi.fn().mockReturnValue(of({ member: [request], totalItems: 1 })), + get: vi.fn().mockReturnValue(of(request)), + create: vi.fn().mockReturnValue(of(request)), + update: vi.fn().mockReturnValue(of(request)), + }; + TestBed.configureTestingModule({ + providers: [ + ...context.providers, + provideRouter( + [ + { + path: 'organizations/:organizationId/service-requests', + children: SERVICE_REQUEST_ROUTES, + }, + { path: 'auth/login', component: DestinationPage }, + { path: 'other', component: DestinationPage }, + ], + withComponentInputBinding(), + ), + { provide: PLATFORM_ID, useValue: 'browser' }, + { provide: ServiceRequestService, useValue: api }, + { + provide: EquipmentService, + useValue: { + list: vi.fn().mockReturnValue(of({ member: [], totalItems: 0 })), + listByFacility: vi.fn().mockReturnValue(of({ member: [], totalItems: 0 })), + get: vi.fn(), + openWork: vi.fn(), + }, + }, + { provide: EquipmentTypeService, useValue: { listAll: vi.fn().mockReturnValue(of([])) } }, + { + provide: FacilityService, + useValue: { + list: vi.fn().mockReturnValue(of({ member: [], totalItems: 0 })), + get: vi.fn(), + }, + }, + { provide: ConnectivityService, useValue: { isOnline: signal(true) } }, + { + provide: OrganizationPermissionService, + useValue: { + hasPermission: (permission: string) => context.grants().includes(permission), + canAccessOrganization: () => true, + }, + }, + { + provide: OrganizationMemberAccessStore, + useValue: { ensureAccessResolved: () => of(undefined) }, + }, + { provide: FeedbackService, useValue: { warn: vi.fn() } }, + { provide: THEME_PORT, useValue: { resolvedTheme: signal('light') } }, + { + provide: REGIONAL_FORMATTING_PORT, + useValue: { regionalFormatting: signal(DEFAULT_REGIONAL_FORMAT_SETTINGS) }, + }, + { + provide: INTERACTION_CAPABILITIES_PORT, + useValue: { + isMobileInteractionMode: signal(false), + interactionMode: signal('desktop'), + shortcutModifier: signal('Control'), + }, + }, + ], + }); + const url = `/organizations/org/service-requests${pageKind === 'detail' ? '/request' : ''}`; + const harness = await RouterTestingHarness.create(url); + await harness.fixture.whenStable(); + const routed = harness.routeDebugElement; + if (!routed) throw new Error('The routed request page is missing.'); + const store = routed.injector.get(ServiceRequestStore); + const destroyed = vi.fn(); + routed.injector.get(DestroyRef).onDestroy(destroyed); + const sheet = routed.query(By.directive(ServiceRequestEditorSheet)) + .componentInstance as ServiceRequestEditorSheet; + return { harness, context, api, url, store, sheet, destroyed }; + } + + describe.each(['directory', 'detail'] as const)('%s page navigation', (pageKind) => { + it.each(['logout', 'session expiration'] as const)( + 'allows the login redirect after %s while journal restoration is pending', + async (termination) => { + const rendered = await render(pageKind, 'pending'); + expect(rendered.store.commandCallState().status).toBe('pending'); + expect(rendered.store.commandsReady()).toBe(false); + rendered.context.authenticated.set(false); + rendered.context.revision.update((revision) => revision + 1); + const login = + termination === 'logout' + ? '/auth/login' + : `/auth/login?returnUrl=${encodeURIComponent(rendered.url)}`; + expect(await TestBed.inject(Router).navigateByUrl(login, { replaceUrl: true })).toBe(true); + expect(TestBed.inject(Router).url).toBe(login); + expect(rendered.destroyed).toHaveBeenCalledOnce(); + }, + ); + + it.each(['pending', 'error'] as const)( + 'allows ordinary navigation while journal restoration is %s without an editor or write', + async (journalState) => { + const rendered = await render(pageKind, journalState); + expect(rendered.context.authenticated()).toBe(true); + expect(rendered.store.commandCallState().status).toBe(journalState); + expect(rendered.store.commandsReady()).toBe(false); + expect(rendered.store.writeCallState().status).toBe('idle'); + expect(rendered.sheet.visible()).toBe(false); + await rendered.harness.navigateByUrl('/other', DestinationPage); + expect(rendered.destroyed).toHaveBeenCalledOnce(); + }, + ); + + it('blocks ordinary pending-write navigation until the local session ends', async () => { + const rendered = await render(pageKind); + const accepted = new Subject(); + rendered.api.create.mockReturnValue(accepted); + rendered.api.update.mockReturnValue(accepted); + const action = + pageKind === 'directory' + ? rendered.harness.routeNativeElement?.querySelector( + '[data-testid="service-request-new"]', + ) + : [...(rendered.harness.routeNativeElement?.querySelectorAll('button') ?? [])].find( + (button) => button.textContent?.trim() === 'Edit', + ); + if (!action) throw new Error('The request editor action is missing.'); + action.click(); + await rendered.harness.fixture.whenStable(); + rendered.sheet.descriptionSubmitted.emit({ + title: 'Retained maintenance write', + description: 'Repair the damaged gauge.', + equipmentId: 'equipment', + }); + await rendered.harness.fixture.whenStable(); + expect(rendered.store.writeCallState().status).toBe('pending'); + expect(await TestBed.inject(Router).navigateByUrl('/other')).toBe(false); + expect(TestBed.inject(Router).url).toBe(rendered.url); + expect(rendered.destroyed).not.toHaveBeenCalled(); + expect(accepted.observed).toBe(true); + rendered.context.authenticated.set(false); + expect(rendered.store.commandsReady()).toBe(false); + expect(await TestBed.inject(Router).navigateByUrl('/auth/login')).toBe(true); + expect(rendered.destroyed).toHaveBeenCalledOnce(); + }); + + it('retains a real dirty editor when the authenticated reader cancels navigation', async () => { + const rendered = await render(pageKind); + const action = + pageKind === 'directory' + ? rendered.harness.routeNativeElement?.querySelector( + '[data-testid="service-request-new"]', + ) + : [...(rendered.harness.routeNativeElement?.querySelectorAll('button') ?? [])].find( + (button) => button.textContent?.trim() === 'Edit', + ); + if (!action) throw new Error('The request editor action is missing.'); + action.click(); + await rendered.harness.fixture.whenStable(); + const title = document.querySelector('#service-request-title'); + if (!title) throw new Error('The real request editor title is missing.'); + title.value = 'Retain this entered draft'; + title.dispatchEvent(new Event('input', { bubbles: true })); + await rendered.harness.fixture.whenStable(); + expect(rendered.sheet.hasDirty()).toBe(true); + const navigation = TestBed.inject(Router).navigateByUrl('/other'); + await vi.waitFor(() => { + rendered.harness.detectChanges(); + expect(document.querySelector('[data-testid="unsaved-changes-dialog"]')).not.toBeNull(); + }); + const cancel = [ + ...document.querySelectorAll( + '[data-testid="unsaved-changes-dialog"] button', + ), + ].find((button) => button.textContent?.trim() === 'Cancel'); + if (!cancel) throw new Error('The native discard confirmation cancel action is missing.'); + cancel.click(); + expect(await navigation).toBe(false); + expect(TestBed.inject(Router).url).toBe(rendered.url); + expect(title.value).toBe('Retain this entered draft'); + expect(rendered.sheet.hasDirty()).toBe(true); + expect(rendered.destroyed).not.toHaveBeenCalled(); + rendered.context.authenticated.set(false); + expect(await TestBed.inject(Router).navigateByUrl('/auth/login')).toBe(true); + expect(document.querySelector('[data-testid="unsaved-changes-dialog"]')).toBeNull(); + expect(rendered.destroyed).toHaveBeenCalledOnce(); + }); + }); +}); diff --git a/src/app/features/organization/features/service-requests/models/index.ts b/src/app/features/organization/features/service-requests/models/index.ts new file mode 100644 index 000000000..4a4a6ecc8 --- /dev/null +++ b/src/app/features/organization/features/service-requests/models/index.ts @@ -0,0 +1,14 @@ +export type { + ServiceRequestOutput, + ServiceRequestStatus, + ServiceRequestPriority, + ServiceRequestTargetSnapshot, +} from './service-request/service-request-output.interface'; +export type { + CreateServiceRequestInput, + UpdateServiceRequestInput, + QualifyServiceRequestInput, + DecisionServiceRequestInput, + ConvertServiceRequestInput, +} from './service-request/service-request-input.interface'; +export type { ServiceRequestConversionCommand } from './service-request-command/service-request-conversion-command.interface'; diff --git a/src/app/features/organization/features/service-requests/models/service-request-command/service-request-conversion-command.interface.ts b/src/app/features/organization/features/service-requests/models/service-request-command/service-request-conversion-command.interface.ts new file mode 100644 index 000000000..efd1add33 --- /dev/null +++ b/src/app/features/organization/features/service-requests/models/service-request-command/service-request-conversion-command.interface.ts @@ -0,0 +1,66 @@ +import type { ConvertServiceRequestInput, ServiceRequestOutput } from '../index'; + +/** + * Interface ServiceRequestConversionCommand + * @interface ServiceRequestConversionCommand + * + * @description + * Account-bound immutable conversion journal entry retaining its original request revision and + * UUID. + */ +export interface ServiceRequestConversionCommand { + /** + * Property kind + * @readonly + * + * @description + * Only conversion has a server-supported replay identity. + * + * @type {'convert'} + */ + readonly kind: 'convert'; + + /** + * Property userId + * @readonly + * + * @description + * Account that accepted this conversion; no credentials are stored. + * + * @type {string} + */ + readonly userId: string; + + /** + * Property organizationId + * @readonly + * + * @description + * Original organization authority, never rebound on retry. + * + * @type {string} + */ + readonly organizationId: string; + + /** + * Property request + * @readonly + * + * @description + * Captured request identity and revision used by the original command. + * + * @type {ServiceRequestOutput} + */ + readonly request: ServiceRequestOutput; + + /** + * Property input + * @readonly + * + * @description + * Exact operation UUID and selected work tuple submitted to the server. + * + * @type {ConvertServiceRequestInput} + */ + readonly input: ConvertServiceRequestInput; +} diff --git a/src/app/features/organization/features/service-requests/models/service-request/service-request-input.interface.ts b/src/app/features/organization/features/service-requests/models/service-request/service-request-input.interface.ts new file mode 100644 index 000000000..4b7c43592 --- /dev/null +++ b/src/app/features/organization/features/service-requests/models/service-request/service-request-input.interface.ts @@ -0,0 +1,280 @@ +import type { ServiceRequestPriority } from './service-request-output.interface'; + +/** + * Interface CreateServiceRequestInput + * @interface + * + * @description + * Initial description and authorized target, with optional source inspection. + * + * @since unreleased + */ +export interface CreateServiceRequestInput { + /** + * Property equipmentId + * @readonly + * + * @description + * Equipment target, when known. + * + * @access public + * @since unreleased + * + * @type {string | null} + */ + readonly equipmentId?: string | null; + + /** + * Property siteId + * @readonly + * + * @description + * Root site target; equipment or site is required. + * + * @access public + * @since unreleased + * + * @type {string | null} + */ + readonly siteId?: string | null; + + /** + * Property title + * @readonly + * + * @description + * Required title up to 160 characters. + * + * @access public + * @since unreleased + * + * @type {string} + */ + readonly title: string; + + /** + * Property description + * @readonly + * + * @description + * Required description up to 10000 characters. + * + * @access public + * @since unreleased + * + * @type {string} + */ + readonly description: string; + + /** + * Property priority + * @readonly + * + * @description + * Optional declared priority; normal is the server default. + * + * @access public + * @since unreleased + * + * @type {ServiceRequestPriority} + */ + readonly priority?: ServiceRequestPriority; + + /** + * Property originInspectionId + * @readonly + * + * @description + * Optional source inspection. + * + * @access public + * @since unreleased + * + * @type {string | null} + */ + readonly originInspectionId?: string | null; + + /** + * Property originNonConformityId + * @readonly + * + * @description + * Optional source anomaly. + * + * @access public + * @since unreleased + * + * @type {string | null} + */ + readonly originNonConformityId?: string | null; +} + +/** + * Interface UpdateServiceRequestInput + * @interface + * + * @description + * Editable description only while the request remains requested. + * + * @since unreleased + */ +export interface UpdateServiceRequestInput { + /** + * Property title + * @readonly + * + * @description + * Updated title up to 160 characters. + * + * @access public + * @since unreleased + * + * @type {string} + */ + readonly title?: string; + + /** + * Property description + * @readonly + * + * @description + * Updated description up to 10000 characters. + * + * @access public + * @since unreleased + * + * @type {string} + */ + readonly description?: string; + + /** + * Property priority + * @readonly + * + * @description + * Updated priority. + * + * @access public + * @since unreleased + * + * @type {ServiceRequestPriority} + */ + readonly priority?: ServiceRequestPriority; +} + +/** + * Interface QualifyServiceRequestInput + * @interface + * + * @description + * Explicit qualification; site-only requests must select one equipment from that site. + * + * @since unreleased + */ +export interface QualifyServiceRequestInput { + /** + * Property equipmentId + * @readonly + * + * @description + * Equipment selected for a site-only request. + * + * @access public + * @since unreleased + * + * @type {string | null} + */ + readonly equipmentId?: string | null; + + /** + * Property note + * @readonly + * + * @description + * Optional qualification explanation up to 10000 characters. + * + * @access public + * @since unreleased + * + * @type {string | null} + */ + readonly note?: string | null; +} + +/** + * Interface DecisionServiceRequestInput + * @interface + * + * @description + * Motivated rejection or cancellation. + * + * @since unreleased + */ +export interface DecisionServiceRequestInput { + /** + * Property reason + * @readonly + * + * @description + * Required explanation up to 2000 characters. + * + * @access public + * @since unreleased + * + * @type {string} + */ + readonly reason: string; +} + +/** + * Interface ConvertServiceRequestInput + * @interface + * + * @description + * Stable conversion command preserved exactly for uncertain response replay. + * + * @since unreleased + */ +export interface ConvertServiceRequestInput { + /** + * Property clientOperationId + * @readonly + * + * @description + * Stable UUID for this conversion attempt. + * + * @access public + * @since unreleased + * + * @type {string} + */ + readonly clientOperationId: string; + + /** + * Property existingInterventionId + * @readonly + * + * @description + * Optional intervention reused instead of creating a new one. + * + * @access public + * @since unreleased + * + * @type {string | null} + */ + readonly existingInterventionId?: string | null; + + /** + * Property existingTaskId + * @readonly + * + * @description + * Optional existing corrective task within the selected intervention. + * + * @access public + * @since unreleased + * + * @type {string | null} + */ + readonly existingTaskId?: string | null; +} diff --git a/src/app/features/organization/features/service-requests/models/service-request/service-request-output.interface.ts b/src/app/features/organization/features/service-requests/models/service-request/service-request-output.interface.ts new file mode 100644 index 000000000..5635f2635 --- /dev/null +++ b/src/app/features/organization/features/service-requests/models/service-request/service-request-output.interface.ts @@ -0,0 +1,410 @@ +import type { HydraItem } from '@core/api/models'; + +/** + * Type ServiceRequestStatus + * + * @description + * Stable server workflow states. + * + * @since unreleased + * + * @type {ServiceRequestStatus} + */ +export type ServiceRequestStatus = + | 'requested' + | 'qualified' + | 'rejected' + | 'cancelled' + | 'converted'; + +/** + * Type ServiceRequestPriority + * + * @description + * Declared maintenance priority. + * + * @since unreleased + * + * @type {ServiceRequestPriority} + */ +export type ServiceRequestPriority = 'low' | 'normal' | 'high' | 'urgent'; + +/** + * Interface ServiceRequestTargetSnapshot + * @interface + * + * @description + * Frozen minimal target identity published by the owning API. + * + * @since unreleased + */ +export interface ServiceRequestTargetSnapshot { + /** + * Property equipment + * @readonly + * + * @description + * Retained equipment identity and declared status. + * + * @access public + * @since unreleased + * + * @type {{ + * readonly id: string; + * readonly name: string | null; + * readonly assetCode: string | null; + * readonly status: string | null; + * } | null} + */ + readonly equipment: { + readonly id: string; + readonly name: string | null; + readonly assetCode: string | null; + readonly status: string | null; + } | null; + /** + * Property site + * @readonly + * + * @description + * Retained root-site identity. + * + * @access public + * @since unreleased + * + * @type {{ readonly id: string; readonly name: string } | null} + */ + readonly site: { readonly id: string; readonly name: string } | null; + /** + * Property customer + * @readonly + * + * @description + * Optional retained internal customer identity; contacts require separate rights. + * + * @access public + * @since unreleased + * + * @type {{ readonly id: string; readonly name: string } | null} + */ + readonly customer: { readonly id: string; readonly name: string } | null; +} + +/** + * Interface ServiceRequestOutput + * @interface + * + * @description + * Internal request, retained context and immutable work link returned by all mutations. + * + * @since unreleased + */ +export interface ServiceRequestOutput extends HydraItem { + /** + * Property id + * @readonly + * + * @description + * Stable maintenance request identity. + * + * @access public + * @since unreleased + * + * @type {string} + */ + readonly id: string; + + /** + * Property organizationId + * @readonly + * + * @description + * Organization that owns this internal request. + * + * @access public + * @since unreleased + * + * @type {string} + */ + readonly organizationId: string; + + /** + * Property equipmentId + * @readonly + * + * @description + * Target equipment fixed after qualification. + * + * @access public + * @since unreleased + * + * @type {string | null} + */ + readonly equipmentId: string | null; + + /** + * Property siteId + * @readonly + * + * @description + * Target root site retained for the request. + * + * @access public + * @since unreleased + * + * @type {string | null} + */ + readonly siteId: string | null; + + /** + * Property title + * @readonly + * + * @description + * Short request title. + * + * @access public + * @since unreleased + * + * @type {string} + */ + readonly title: string; + + /** + * Property description + * @readonly + * + * @description + * Description of the maintenance need. + * + * @access public + * @since unreleased + * + * @type {string} + */ + readonly description: string; + + /** + * Property priority + * @readonly + * + * @description + * Declared scheduling priority. + * + * @access public + * @since unreleased + * + * @type {ServiceRequestPriority} + */ + readonly priority: ServiceRequestPriority; + + /** + * Property status + * @readonly + * + * @description + * Server-confirmed qualification and conversion state. + * + * @access public + * @since unreleased + * + * @type {ServiceRequestStatus} + */ + readonly status: ServiceRequestStatus; + + /** + * Property originInspectionId + * @readonly + * + * @description + * Optional source inspection identity. + * + * @access public + * @since unreleased + * + * @type {string | null} + */ + readonly originInspectionId: string | null; + + /** + * Property originNonConformityId + * @readonly + * + * @description + * Optional source anomaly identity. + * + * @access public + * @since unreleased + * + * @type {string | null} + */ + readonly originNonConformityId: string | null; + + /** + * Property targetSnapshot + * @readonly + * + * @description + * Minimal retained identity without customer contacts. + * + * @access public + * @since unreleased + * + * @type {ServiceRequestTargetSnapshot} + */ + readonly targetSnapshot: ServiceRequestTargetSnapshot; + + /** + * Property qualificationNote + * @readonly + * + * @description + * Recorded qualification explanation. + * + * @access public + * @since unreleased + * + * @type {string | null} + */ + readonly qualificationNote: string | null; + + /** + * Property decisionReason + * @readonly + * + * @description + * Reason for rejection or cancellation. + * + * @access public + * @since unreleased + * + * @type {string | null} + */ + readonly decisionReason: string | null; + + /** + * Property interventionId + * @readonly + * + * @description + * Confirmed intervention created or reused by conversion. + * + * @access public + * @since unreleased + * + * @type {string | null} + */ + readonly interventionId: string | null; + + /** + * Property taskId + * @readonly + * + * @description + * Confirmed corrective task identity. + * + * @access public + * @since unreleased + * + * @type {string | null} + */ + readonly taskId: string | null; + + /** + * Property requestedAt + * @readonly + * + * @description + * Creation instant. + * + * @access public + * @since unreleased + * + * @type {string} + */ + readonly requestedAt: string; + + /** + * Property updatedAt + * @readonly + * + * @description + * Last accepted mutation instant. + * + * @access public + * @since unreleased + * + * @type {string} + */ + readonly updatedAt: string; + + /** + * Property qualifiedAt + * @readonly + * + * @description + * Qualification instant when accepted. + * + * @access public + * @since unreleased + * + * @type {string | null} + */ + readonly qualifiedAt: string | null; + + /** + * Property rejectedAt + * @readonly + * + * @description + * Rejection instant when accepted. + * + * @access public + * @since unreleased + * + * @type {string | null} + */ + readonly rejectedAt: string | null; + + /** + * Property cancelledAt + * @readonly + * + * @description + * Cancellation instant when accepted. + * + * @access public + * @since unreleased + * + * @type {string | null} + */ + readonly cancelledAt: string | null; + + /** + * Property convertedAt + * @readonly + * + * @description + * Conversion instant when accepted. + * + * @access public + * @since unreleased + * + * @type {string | null} + */ + readonly convertedAt: string | null; + + /** + * Property revision + * @readonly + * + * @description + * Optimistic revision sent in quoted If-Match headers. + * + * @access public + * @since unreleased + * + * @type {number} + */ + readonly revision: number; +} diff --git a/src/app/features/organization/features/service-requests/models/service-request/testing/service-request.fixture.ts b/src/app/features/organization/features/service-requests/models/service-request/testing/service-request.fixture.ts new file mode 100644 index 000000000..db0ec3e5f --- /dev/null +++ b/src/app/features/organization/features/service-requests/models/service-request/testing/service-request.fixture.ts @@ -0,0 +1,55 @@ +import type { ServiceRequestOutput } from '../service-request-output.interface'; +/** + * Function serviceRequestFixture + * + * @description + * Typed baseline transport projection for focused request workflow regression tests. + * + * @access public + * @since unreleased + * + * @param {Partial} overrides - Transport fields specific to the regression + * scenario. + * + * @returns {ServiceRequestOutput} Result owned by the request workflow. + */ +export function serviceRequestFixture( + overrides: Partial = {}, +): ServiceRequestOutput { + return { + '@id': '/api/organizations/org/service-requests/request', + '@type': 'ServiceRequest', + id: 'request', + organizationId: 'org', + equipmentId: 'equipment', + siteId: 'site', + title: 'Repair damaged gauge', + description: 'The pressure gauge is damaged.', + priority: 'normal', + status: 'requested', + originInspectionId: null, + originNonConformityId: null, + targetSnapshot: { + equipment: { + id: 'equipment', + name: 'Extinguisher A', + assetCode: 'FIRE-1', + status: 'operational', + }, + site: { id: 'site', name: 'Hospital' }, + customer: { id: 'customer', name: 'Hospital operator' }, + }, + qualificationNote: null, + decisionReason: null, + interventionId: null, + taskId: null, + requestedAt: '2026-10-06T10:00:00Z', + updatedAt: '2026-10-06T10:00:00Z', + qualifiedAt: null, + rejectedAt: null, + cancelledAt: null, + convertedAt: null, + revision: 1, + ...overrides, + }; +} diff --git a/src/app/features/organization/features/service-requests/service-requests.routes.ts b/src/app/features/organization/features/service-requests/service-requests.routes.ts new file mode 100644 index 000000000..ebe0c107f --- /dev/null +++ b/src/app/features/organization/features/service-requests/service-requests.routes.ts @@ -0,0 +1,52 @@ +import type { Routes } from '@angular/router'; +import { DASHBOARD_MOBILE_NAVIGATION_ROOT_DATA_KEY, type DashboardRouteData } from '@core/routing'; +import { serviceRequestDraftLeaveGuard } from '@features/organization/features/service-requests/http/guards'; +import { organizationPermissionGuard } from '@features/organization/http/guards'; +import { ORGANIZATION_PERMISSION } from '@features/organization/models'; +/** + * Constant SERVICE_REQUEST_ROUTES + * + * @description + * Organization-scoped collection and request dossier with separate read/create route grants. + * + * @since unreleased + */ +export const SERVICE_REQUEST_ROUTES: Routes = [ + { + path: '', + canDeactivate: [serviceRequestDraftLeaveGuard], + canActivate: [ + organizationPermissionGuard({ + permissions: [ + ORGANIZATION_PERMISSION.SERVICE_REQUESTS_READ, + ORGANIZATION_PERMISSION.SERVICE_REQUESTS_CREATE, + ], + match: 'any', + }), + ], + loadComponent: () => + import('./ui/pages/service-requests-page/service-requests-page.component').then( + (module) => module.ServiceRequestsPage, + ), + title: $localize`:@@route.serviceRequests:Repair requests`, + data: { + breadcrumb: $localize`:@@route.serviceRequests:Repair requests`, + [DASHBOARD_MOBILE_NAVIGATION_ROOT_DATA_KEY]: true, + } satisfies DashboardRouteData, + }, + { + path: ':requestId', + canDeactivate: [serviceRequestDraftLeaveGuard], + canActivate: [ + organizationPermissionGuard({ permissions: [ORGANIZATION_PERMISSION.SERVICE_REQUESTS_READ] }), + ], + loadComponent: () => + import('./ui/pages/service-request-detail-page/service-request-detail-page.component').then( + (module) => module.ServiceRequestDetailPage, + ), + title: $localize`:@@serviceRequest.detail.title:Maintenance request`, + data: { + breadcrumb: $localize`:@@serviceRequest.detail.title:Maintenance request`, + } satisfies DashboardRouteData, + }, +]; diff --git a/src/app/features/organization/features/service-requests/services/index.ts b/src/app/features/organization/features/service-requests/services/index.ts new file mode 100644 index 000000000..f096cc399 --- /dev/null +++ b/src/app/features/organization/features/service-requests/services/index.ts @@ -0,0 +1,4 @@ +export { + ServiceRequestConversionService, + ServiceRequestPersistenceError, +} from './service-request-conversion/service-request-conversion.service'; diff --git a/src/app/features/organization/features/service-requests/services/service-request-conversion/service-request-conversion.service.ts b/src/app/features/organization/features/service-requests/services/service-request-conversion/service-request-conversion.service.ts new file mode 100644 index 000000000..c6da1df08 --- /dev/null +++ b/src/app/features/organization/features/service-requests/services/service-request-conversion/service-request-conversion.service.ts @@ -0,0 +1,165 @@ +import { inject, Service } from '@angular/core'; +import { + catchError, + concatMap, + defer, + EMPTY, + finalize, + from, + map, + shareReplay, + switchMap, + throwError, + type Observable, +} from 'rxjs'; +import { toStoreError } from '@core/request-state'; +import { + ServiceRequestCommandRepository, + ServiceRequestService, +} from '@features/organization/features/service-requests/data-access'; +import type { + ServiceRequestConversionCommand, + ServiceRequestOutput, +} from '@features/organization/features/service-requests/models'; + +/** + * Class ServiceRequestPersistenceError + * @class ServiceRequestPersistenceError + * + * @description + * Distinguishes a local acceptance failure from a conversion already transmitted to the server. + */ +export class ServiceRequestPersistenceError extends Error { + //#region Constructor + /** + * Constructor + * @constructor + * + * @description + * Keeps the storage cause while reporting that the native draft remains available to retry. + * + * @access public + * @since unreleased + * + * @param {unknown} cause - Local journal acceptance failure. + */ + public constructor(cause: unknown) { + super( + $localize`:@@serviceRequest.command.persistFailed:This conversion could not be saved on this device. Your draft has been kept. Retry to send it.`, + { cause }, + ); + this.name = 'ServiceRequestPersistenceError'; + } + //#endregion +} + +/** + * Class ServiceRequestConversionService + * @class ServiceRequestConversionService + * + * @description + * Root conversion coordinator completes accepted writes independently of any routed page lifetime. + */ +@Service() +export class ServiceRequestConversionService { + //#region Properties + /** + * Property journal + * @readonly + * + * @description + * Durable acceptance and account/session fences for replayable conversions. + * + * @access private + * @since unreleased + * + * @type {ServiceRequestCommandRepository} + */ + private readonly journal: ServiceRequestCommandRepository = inject( + ServiceRequestCommandRepository, + ); + + /** + * Property service + * @readonly + * + * @description + * Feature-owned conversion transport. + * + * @access private + * @since unreleased + * + * @type {ServiceRequestService} + */ + private readonly service: ServiceRequestService = inject(ServiceRequestService); + + /** + * Property accepted + * @readonly + * + * @description + * Same-session retries join a still-running write rather than transmitting another request. + * + * @access private + * @since unreleased + * + * @type {Map>} + */ + private readonly accepted: Map> = new Map(); + //#endregion + + //#region Methods + /** + * Method execute + * @method execute + * + * @description + * Persists before transmission, keeps uncertain outcomes, and acknowledges only confirmed + * replies. + * + * @access public + * @since unreleased + * + * @param {ServiceRequestConversionCommand} command - Immutable conversion declaration. + * @param {number} revision - Authentication generation captured before acceptance. + * + * @returns {Observable} Shared accepted outcome surviving page destruction. + */ + public execute( + command: ServiceRequestConversionCommand, + revision: number, + ): Observable { + const key: string = `${command.userId}/${revision}/${command.organizationId}/${command.input.clientOperationId}`; + const previous: Observable | undefined = this.accepted.get(key); + if (previous) return previous; + const current = () => this.journal.isCurrent(command.userId, command.organizationId, revision); + const accepted: Observable = defer(() => + from(this.journal.retain(command, revision)), + ).pipe( + catchError((error: unknown) => throwError(() => new ServiceRequestPersistenceError(error))), + switchMap(() => + current() + ? this.service.convert(command.organizationId, command.request, command.input).pipe( + catchError((error: unknown) => { + const failure = toStoreError(error); + const code: number = Number(failure.code); + return code >= 400 && code < 500 && !failure.retryable + ? from(this.journal.acknowledge(command, revision)).pipe( + switchMap(() => throwError(() => error)), + ) + : throwError(() => error); + }), + ) + : EMPTY, + ), + concatMap((request) => + from(this.journal.acknowledge(command, revision)).pipe(map(() => request)), + ), + finalize(() => this.accepted.delete(key)), + shareReplay({ bufferSize: 1, refCount: false }), + ); + this.accepted.set(key, accepted); + return accepted; + } + //#endregion +} diff --git a/src/app/features/organization/features/service-requests/services/service-request-conversion/testing/service-request-conversion.service.spec.ts b/src/app/features/organization/features/service-requests/services/service-request-conversion/testing/service-request-conversion.service.spec.ts new file mode 100644 index 000000000..1c864cd60 --- /dev/null +++ b/src/app/features/organization/features/service-requests/services/service-request-conversion/testing/service-request-conversion.service.spec.ts @@ -0,0 +1,98 @@ +import { HttpErrorResponse } from '@angular/common/http'; +import { TestBed } from '@angular/core/testing'; +import { of, Subject, throwError } from 'rxjs'; +import { ServiceRequestService } from '@features/organization/features/service-requests/data-access'; +import type { + ServiceRequestConversionCommand, + ServiceRequestOutput, +} from '@features/organization/features/service-requests/models'; +import { serviceRequestFixture } from '@features/organization/features/service-requests/models/service-request/testing/service-request.fixture'; +import { serviceRequestContext } from '@features/organization/features/service-requests/state/service-request/testing/service-request-context.fixture'; +import { ServiceRequestConversionService } from '../service-request-conversion.service'; + +describe('ServiceRequestConversionService', () => { + const command: ServiceRequestConversionCommand = { + kind: 'convert', + userId: 'user', + organizationId: 'org', + request: serviceRequestFixture({ revision: 3, status: 'qualified' }), + input: { + clientOperationId: 'operation', + existingInterventionId: 'work', + existingTaskId: 'task', + }, + }; + function setup() { + const context = serviceRequestContext(); + const api = { + convert: vi + .fn() + .mockReturnValue(of(serviceRequestFixture({ ...command.request, status: 'converted' }))), + }; + TestBed.configureTestingModule({ + providers: [...context.providers, { provide: ServiceRequestService, useValue: api }], + }); + return { context, api, service: TestBed.inject(ServiceRequestConversionService) }; + } + + it('waits for durable commit and rejects an old session before any network transmission', async () => { + const { context, api, service } = setup(); + let commit: () => void = vi.fn(); + context.journal.retain.mockImplementation( + () => + new Promise((resolve) => { + commit = resolve; + }), + ); + const result = service.execute(command, 1).subscribe(); + await vi.waitFor(() => expect(context.journal.retain).toHaveBeenCalledWith(command, 1)); + expect(api.convert).not.toHaveBeenCalled(); + context.revision.set(2); + commit(); + await vi.waitFor(() => expect(result.closed).toBe(true)); + expect(api.convert).not.toHaveBeenCalled(); + expect(context.journal.acknowledge).not.toHaveBeenCalled(); + }); + + it('keeps an accepted transmission alive after the last routed subscriber is destroyed', async () => { + const { context, api, service } = setup(); + const response = new Subject(); + api.convert.mockReturnValue(response); + const result = service.execute(command, 1).subscribe(); + await vi.waitFor(() => expect(api.convert).toHaveBeenCalledOnce()); + result.unsubscribe(); + expect(response.observed).toBe(true); + response.next( + serviceRequestFixture({ status: 'converted', interventionId: 'work', taskId: 'task' }), + ); + response.complete(); + await vi.waitFor(() => expect(context.commands.size).toBe(0)); + expect(context.journal.acknowledge).toHaveBeenCalledWith(command, 1); + }); + + it('retains status-zero outcomes and only removes a confirmed response', async () => { + const { context, api, service } = setup(); + api.convert.mockReturnValueOnce(throwError(() => new HttpErrorResponse({ status: 0 }))); + const failed = vi.fn(); + service.execute(command, 1).subscribe({ error: failed }); + await vi.waitFor(() => expect(failed).toHaveBeenCalledOnce()); + expect(context.commands.get('operation')).toEqual(command); + expect(context.journal.acknowledge).not.toHaveBeenCalled(); + service.execute(command, 1).subscribe(); + await vi.waitFor(() => expect(context.commands.size).toBe(0)); + expect(api.convert).toHaveBeenLastCalledWith('org', command.request, command.input); + }); + + it('does not acknowledge a confirmed old-session response in a replacement account', async () => { + const { context, api, service } = setup(); + const response = new Subject(); + api.convert.mockReturnValue(response); + service.execute(command, 1).subscribe(); + await vi.waitFor(() => expect(api.convert).toHaveBeenCalledOnce()); + context.revision.set(2); + response.next(serviceRequestFixture({ status: 'converted' })); + response.complete(); + await Promise.resolve(); + expect(context.commands.size).toBe(1); + }); +}); diff --git a/src/app/features/organization/features/service-requests/state/index.ts b/src/app/features/organization/features/service-requests/state/index.ts new file mode 100644 index 000000000..c678ed75d --- /dev/null +++ b/src/app/features/organization/features/service-requests/state/index.ts @@ -0,0 +1,12 @@ +export { + ServiceRequestStore, + type ServiceRequestStoreType, + type ServiceRequestQuery, + type ServiceRequestCommand, + serviceRequestStoreEvents, +} from './service-request'; +export { + ServiceRequestTargetStore, + type ServiceRequestTargetStoreType, + type ServiceRequestTargetQuery, +} from './service-request-target'; diff --git a/src/app/features/organization/features/service-requests/state/service-request-target/index.ts b/src/app/features/organization/features/service-requests/state/service-request-target/index.ts new file mode 100644 index 000000000..9d1d91e91 --- /dev/null +++ b/src/app/features/organization/features/service-requests/state/service-request-target/index.ts @@ -0,0 +1,5 @@ +export { + ServiceRequestTargetStore, + type ServiceRequestTargetStoreType, + type ServiceRequestTargetQuery, +} from './service-request-target.store'; diff --git a/src/app/features/organization/features/service-requests/state/service-request-target/service-request-target.store.ts b/src/app/features/organization/features/service-requests/state/service-request-target/service-request-target.store.ts new file mode 100644 index 000000000..fce96ad09 --- /dev/null +++ b/src/app/features/organization/features/service-requests/state/service-request-target/service-request-target.store.ts @@ -0,0 +1,402 @@ +import { computed, inject } from '@angular/core'; +import { tapResponse } from '@ngrx/operators'; +import { patchState, signalStore, type, withComputed, withMethods, withState } from '@ngrx/signals'; +import { removeAllEntities, setAllEntities, withEntities } from '@ngrx/signals/entities'; +import { rxMethod } from '@ngrx/signals/rxjs-interop'; +import { EMPTY, pipe, switchMap } from 'rxjs'; +import type { RequestOptions } from '@core/api'; +import { + idleCallState, + pendingCallState, + successCallState, + errorCallState, + toStoreError, + type CallState, +} from '@core/request-state'; +import { EquipmentService } from '@features/organization/features/equipments/data-access'; +import type { + EquipmentOutput, + EquipmentOpenWorkOutput, +} from '@features/organization/features/equipments/models'; +import { FacilityService } from '@features/organization/features/facilities/data-access'; +import type { FacilityOutput } from '@features/organization/features/facilities/models'; +import { toFacilityOption } from '@features/organization/features/facilities/utils'; +/** + * Interface ServiceRequestTargetQuery + * @interface + * + * @description + * Authorized target picker scope, remote search and pagination. + * + * @since unreleased + */ +export interface ServiceRequestTargetQuery { + /** + * Property organizationId + * @readonly + * + * @description + * Organization authority shared by the owning route and server transport. + * + * @access public + * @since unreleased + * + * @type {string} + */ + readonly organizationId: string; + /** + * Property siteId + * @readonly + * + * @description + * Root-site target or scope; qualification choices stay inside this site. + * + * @access public + * @since unreleased + * + * @type {string} + */ + readonly siteId?: string; + /** + * Property page + * @readonly + * + * @description + * Current server page, starting at one. + * + * @access public + * @since unreleased + * + * @type {number} + */ + readonly page?: number; + /** + * Property search + * @readonly + * + * @description + * Committed server search term. + * + * @access public + * @since unreleased + * + * @type {string} + */ + readonly search?: string; +} +/** + * Interface ServiceRequestTargetState + * @interface + * + * @description + * Independent candidate, retained-target and authorized open-work read states. + * + * @since unreleased + */ +interface ServiceRequestTargetState { + /** + * Property equipmentQuery + * @readonly + * + * @description + * Current equipment picker authority, site subtree and server page. + * + * @access public + * @since unreleased + * + * @type {ServiceRequestTargetQuery | null} + */ + readonly equipmentQuery: ServiceRequestTargetQuery | null; + /** + * Property equipmentCallState + * @readonly + * + * @description + * Authorized candidate read state without a second copied collection. + * + * @access public + * @since unreleased + * + * @type {CallState} + */ + readonly equipmentCallState: CallState; + /** + * Property equipmentTotal + * @readonly + * + * @description + * Server candidate count including disabled historical entries. + * + * @access public + * @since unreleased + * + * @type {number} + */ + readonly equipmentTotal: number; + /** + * Property selectedEquipmentCallState + * @readonly + * + * @description + * Retained equipment identity hydrated independently of search pages. + * + * @access public + * @since unreleased + * + * @type {CallState} + */ + readonly selectedEquipmentCallState: CallState; + /** + * Property siteQuery + * @readonly + * + * @description + * Current root-site picker authority and server page. + * + * @access public + * @since unreleased + * + * @type {ServiceRequestTargetQuery | null} + */ + readonly siteQuery: ServiceRequestTargetQuery | null; + /** + * Property siteCallState + * @readonly + * + * @description + * Root-site candidate read state. + * + * @access public + * @since unreleased + * + * @type {CallState} + */ + readonly siteCallState: CallState; + /** + * Property siteTotal + * @readonly + * + * @description + * Authoritative root-site count for picker pagination. + * + * @access public + * @since unreleased + * + * @type {number} + */ + readonly siteTotal: number; + /** + * Property selectedSiteCallState + * @readonly + * + * @description + * Retained site identity hydrated independently of search pages. + * + * @access public + * @since unreleased + * + * @type {CallState} + */ + readonly selectedSiteCallState: CallState; + /** + * Property openWorkCallState + * @readonly + * + * @description + * Authorized open-work state; denied and failed reads remain distinct from no work. + * + * @access public + * @since unreleased + * + * @type {CallState} + */ + readonly openWorkCallState: CallState; +} +/** + * Constant initialState + * + * @description + * Idle request state never claims that the server confirmed an empty collection. + * + * @since unreleased + */ +const initialState: ServiceRequestTargetState = { + equipmentQuery: null, + equipmentCallState: idleCallState(), + equipmentTotal: 0, + selectedEquipmentCallState: idleCallState(), + siteQuery: null, + siteCallState: idleCallState(), + siteTotal: 0, + selectedSiteCallState: idleCallState(), + openWorkCallState: idleCallState(), +}; +/** + * Constant ServiceRequestTargetStore + * + * @description + * Reads Equipment and Facility public projections without taking over their lifecycle or + * persistence. + * + * @since unreleased + */ +export const ServiceRequestTargetStore = signalStore( + withState(initialState), + withEntities({ entity: type(), collection: 'equipment' }), + withEntities({ entity: type(), collection: 'site' }), + withComputed((store) => ({ + equipmentPageCount: computed(() => Math.max(1, Math.ceil(store.equipmentTotal() / 30))), + sitePageCount: computed(() => Math.max(1, Math.ceil(store.siteTotal() / 30))), + siteOptions: computed(() => store.siteEntities().map(toFacilityOption)), + selectedSiteOption: computed(() => { + const site = store.selectedSiteCallState().data; + return site ? toFacilityOption(site) : null; + }), + })), + withMethods( + (store, equipment = inject(EquipmentService), facilities = inject(FacilityService)) => ({ + loadEquipment: rxMethod( + pipe( + switchMap((query) => { + patchState(store, removeAllEntities({ collection: 'equipment' }), { + equipmentQuery: query, + equipmentTotal: 0, + equipmentCallState: query ? pendingCallState() : idleCallState(), + }); + if (!query) return EMPTY; + const options: RequestOptions = { + page: query.page ?? 1, + itemsPerPage: 30, + search: query.search, + params: query.siteId ? { includeDescendants: true } : {}, + }; + return ( + query.siteId + ? equipment.listByFacility(query.organizationId, query.siteId, options) + : equipment.list(query.organizationId, options) + ).pipe( + tapResponse({ + next: (collection) => + patchState( + store, + setAllEntities([...collection.member], { collection: 'equipment' }), + { + equipmentTotal: collection.totalItems, + equipmentCallState: successCallState(null), + }, + ), + error: (error: unknown) => + patchState(store, { equipmentCallState: errorCallState(toStoreError(error)) }), + }), + ); + }), + ), + ), + loadSites: rxMethod( + pipe( + switchMap((query) => { + patchState(store, removeAllEntities({ collection: 'site' }), { + siteQuery: query, + siteTotal: 0, + siteCallState: query ? pendingCallState() : idleCallState(), + }); + if (!query) return EMPTY; + return facilities + .list(query.organizationId, { + page: query.page ?? 1, + itemsPerPage: 30, + search: query.search, + params: { rootsOnly: true }, + }) + .pipe( + tapResponse({ + next: (collection) => + patchState( + store, + setAllEntities([...collection.member], { collection: 'site' }), + { + siteTotal: collection.totalItems, + siteCallState: successCallState(null), + }, + ), + error: (error: unknown) => + patchState(store, { siteCallState: errorCallState(toStoreError(error)) }), + }), + ); + }), + ), + ), + readEquipment: rxMethod<{ + readonly organizationId: string; + readonly equipmentId: string; + } | null>( + pipe( + switchMap((query) => { + patchState(store, { + selectedEquipmentCallState: query ? pendingCallState() : idleCallState(), + }); + if (!query) return EMPTY; + return equipment.get(query.organizationId, query.equipmentId).pipe( + tapResponse({ + next: (record) => + patchState(store, { selectedEquipmentCallState: successCallState(record) }), + error: (error: unknown) => + patchState(store, { + selectedEquipmentCallState: errorCallState(toStoreError(error)), + }), + }), + ); + }), + ), + ), + readSite: rxMethod<{ readonly organizationId: string; readonly siteId: string } | null>( + pipe( + switchMap((query) => { + patchState(store, { + selectedSiteCallState: query ? pendingCallState() : idleCallState(), + }); + if (!query) return EMPTY; + return facilities.get(query.organizationId, query.siteId).pipe( + tapResponse({ + next: (record) => + patchState(store, { selectedSiteCallState: successCallState(record) }), + error: (error: unknown) => + patchState(store, { selectedSiteCallState: errorCallState(toStoreError(error)) }), + }), + ); + }), + ), + ), + loadOpenWork: rxMethod<{ + readonly organizationId: string; + readonly equipmentId: string; + } | null>( + pipe( + switchMap((query) => { + patchState(store, { openWorkCallState: query ? pendingCallState() : idleCallState() }); + if (!query) return EMPTY; + return equipment.openWork(query.organizationId, query.equipmentId).pipe( + tapResponse({ + next: (collection) => + patchState(store, { openWorkCallState: successCallState(collection.member) }), + error: (error: unknown) => + patchState(store, { openWorkCallState: errorCallState(toStoreError(error)) }), + }), + ); + }), + ), + ), + }), + ), +); +/** + * Type ServiceRequestTargetStoreType + * + * @description + * Target store instance contract consumed by internal request widgets. + * + * @since unreleased + * + * @type {ServiceRequestTargetStoreType} + */ +export type ServiceRequestTargetStoreType = InstanceType; diff --git a/src/app/features/organization/features/service-requests/state/service-request-target/testing/service-request-target.store.spec.ts b/src/app/features/organization/features/service-requests/state/service-request-target/testing/service-request-target.store.spec.ts new file mode 100644 index 000000000..345446767 --- /dev/null +++ b/src/app/features/organization/features/service-requests/state/service-request-target/testing/service-request-target.store.spec.ts @@ -0,0 +1,104 @@ +import { TestBed } from '@angular/core/testing'; +import { of, Subject } from 'rxjs'; +import type { HydraCollection } from '@core/api/models'; +import { EquipmentService } from '@features/organization/features/equipments/data-access'; +import type { EquipmentOutput } from '@features/organization/features/equipments/models'; +import { FacilityService } from '@features/organization/features/facilities/data-access'; +import type { FacilityOutput } from '@features/organization/features/facilities/models'; +import { + ServiceRequestTargetStore, + type ServiceRequestTargetStoreType, +} from '../service-request-target.store'; +describe('ServiceRequestTargetStore', () => { + const equipment = { + id: 'equipment', + type: 'fire_extinguisher', + name: 'Extinguisher', + status: 'operational', + recordStatus: 'published', + } as unknown as EquipmentOutput; + const site = { + id: 'site', + type: 'site', + name: 'Hospital', + parentFacilityId: null, + } as unknown as FacilityOutput; + let equipmentService: { + list: ReturnType; + listByFacility: ReturnType; + get: ReturnType; + openWork: ReturnType; + }; + let facilities: { list: ReturnType; get: ReturnType }; + let store: ServiceRequestTargetStoreType; + beforeEach(() => { + equipmentService = { + list: vi.fn().mockReturnValue(of({ member: [equipment], totalItems: 95 })), + listByFacility: vi.fn().mockReturnValue(of({ member: [equipment], totalItems: 95 })), + get: vi.fn().mockReturnValue(of(equipment)), + openWork: vi.fn().mockReturnValue(of({ member: [], totalItems: 0 })), + }; + facilities = { + list: vi.fn().mockReturnValue(of({ member: [site], totalItems: 35 })), + get: vi.fn().mockReturnValue(of(site)), + }; + TestBed.configureTestingModule({ + providers: [ + ServiceRequestTargetStore, + { provide: EquipmentService, useValue: equipmentService }, + { provide: FacilityService, useValue: facilities }, + ], + }); + store = TestBed.inject(ServiceRequestTargetStore); + }); + it('queries a site subtree for every qualification page and never forwards a global traversal flag', () => { + store.loadEquipment({ organizationId: 'org', siteId: 'site', page: 2, search: 'Ext' }); + expect(equipmentService.listByFacility).toHaveBeenCalledWith('org', 'site', { + page: 2, + itemsPerPage: 30, + search: 'Ext', + params: { includeDescendants: true }, + }); + expect(store.equipmentPageCount()).toBe(4); + store.loadEquipment({ organizationId: 'org' }); + expect(equipmentService.list).toHaveBeenLastCalledWith('org', { + page: 1, + itemsPerPage: 30, + search: undefined, + params: {}, + }); + }); + it('cancels old organization/site choices and retains the current server count', () => { + const old = new Subject>(); + equipmentService.listByFacility.mockReturnValueOnce(old); + store.loadEquipment({ organizationId: 'org', siteId: 'old' }); + store.loadEquipment({ organizationId: 'other', siteId: 'site' }); + expect(old.observed).toBe(false); + expect(store.equipmentEntities()).toEqual([equipment]); + expect(store.equipmentTotal()).toBe(95); + store.loadEquipment(null); + expect(store.equipmentEntities()).toEqual([]); + expect(store.equipmentCallState().status).toBe('idle'); + }); + it('offers root sites and hydrates their label outside search pages', () => { + store.loadSites({ organizationId: 'org', page: 2 }); + expect(facilities.list).toHaveBeenCalledWith( + 'org', + expect.objectContaining({ page: 2, params: { rootsOnly: true } }), + ); + store.readSite({ organizationId: 'org', siteId: 'site' }); + expect(store.selectedSiteOption()?.label).toBe('Hospital'); + expect(store.siteOptions()[0].type).toBe('site'); + expect(store.sitePageCount()).toBe(2); + }); + it('does not treat denied or failed open-work reads as an empty confirmed queue', () => { + const old = new Subject>(); + equipmentService.openWork.mockReturnValue(old); + store.loadOpenWork({ organizationId: 'org', equipmentId: 'equipment' }); + expect(store.openWorkCallState().status).toBe('pending'); + expect(store.openWorkCallState().data).toBeNull(); + old.error(new Error('Denied')); + expect(store.openWorkCallState().status).toBe('error'); + expect(store.openWorkCallState().data).toBeNull(); + }); +}); diff --git a/src/app/features/organization/features/service-requests/state/service-request/events/events.ts b/src/app/features/organization/features/service-requests/state/service-request/events/events.ts new file mode 100644 index 000000000..94bdb1494 --- /dev/null +++ b/src/app/features/organization/features/service-requests/state/service-request/events/events.ts @@ -0,0 +1,21 @@ +import { type } from '@ngrx/signals'; +import { eventGroup } from '@ngrx/signals/events'; +import type { ServiceRequestOutput } from '@features/organization/features/service-requests/models'; +/** + * Constant serviceRequestStoreEvents + * + * @description + * Server-confirmed mutations report their producing organization, request and action once. + * + * @since unreleased + */ +export const serviceRequestStoreEvents = eventGroup({ + source: 'Service Request Store', + events: { + saved: type<{ + readonly organizationId: string; + readonly request: ServiceRequestOutput; + readonly kind: 'create' | 'update' | 'qualify' | 'reject' | 'cancel' | 'convert'; + }>(), + }, +}); diff --git a/src/app/features/organization/features/service-requests/state/service-request/index.ts b/src/app/features/organization/features/service-requests/state/service-request/index.ts new file mode 100644 index 000000000..866323144 --- /dev/null +++ b/src/app/features/organization/features/service-requests/state/service-request/index.ts @@ -0,0 +1,7 @@ +export { + ServiceRequestStore, + type ServiceRequestStoreType, + type ServiceRequestQuery, + type ServiceRequestCommand, +} from './service-request.store'; +export { serviceRequestStoreEvents } from './events/events'; diff --git a/src/app/features/organization/features/service-requests/state/service-request/service-request.store.ts b/src/app/features/organization/features/service-requests/state/service-request/service-request.store.ts new file mode 100644 index 000000000..a1203bec2 --- /dev/null +++ b/src/app/features/organization/features/service-requests/state/service-request/service-request.store.ts @@ -0,0 +1,798 @@ +import { computed, effect, inject, untracked } from '@angular/core'; +import { tapResponse } from '@ngrx/operators'; +import { + patchState, + signalStore, + type, + withComputed, + withHooks, + withMethods, + withState, +} from '@ngrx/signals'; +import { removeAllEntities, setAllEntities, withEntities } from '@ngrx/signals/entities'; +import { Dispatcher } from '@ngrx/signals/events'; +import { rxMethod } from '@ngrx/signals/rxjs-interop'; +import { EMPTY, exhaustMap, from, pipe, shareReplay, switchMap, type Observable } from 'rxjs'; +import { + idleCallState, + pendingCallState, + successCallState, + errorCallState, + toStoreError, + type CallState, +} from '@core/request-state'; +import { AUTH_SESSION_PORT } from '@features/auth/ports'; +import { + ServiceRequestCommandRepository, + ServiceRequestService, +} from '@features/organization/features/service-requests/data-access'; +import type { + ServiceRequestOutput, + ServiceRequestStatus, + CreateServiceRequestInput, + UpdateServiceRequestInput, + QualifyServiceRequestInput, + DecisionServiceRequestInput, + ConvertServiceRequestInput, + ServiceRequestConversionCommand, +} from '@features/organization/features/service-requests/models'; +import { + ServiceRequestConversionService, + ServiceRequestPersistenceError, +} from '@features/organization/features/service-requests/services'; +import { ORGANIZATION_PERMISSION } from '@features/organization/models'; +import { ORGANIZATION_MEMBER_ACCESS_PORT } from '@features/organization/ports/organization-member-access'; +import { serviceRequestStoreEvents } from './events/events'; + +/** + * Interface ServiceRequestQuery + * @interface + * + * @description + * Authorized server collection query; equipment and site remain distinct scopes. + * + * @since unreleased + */ +export interface ServiceRequestQuery { + /** + * Property organizationId + * @readonly + * + * @description + * Organization authority shared by the owning route and server transport. + * + * @access public + * @since unreleased + * + * @type {string} + */ + readonly organizationId: string; + /** + * Property page + * @readonly + * + * @description + * Current server page, starting at one. + * + * @access public + * @since unreleased + * + * @type {number} + */ + readonly page?: number; + /** + * Property search + * @readonly + * + * @description + * Committed server search term. + * + * @access public + * @since unreleased + * + * @type {string} + */ + readonly search?: string; + /** + * Property status + * @readonly + * + * @description + * Server workflow state or the selected workflow filter. + * + * @access public + * @since unreleased + * + * @type {ServiceRequestStatus} + */ + readonly status?: ServiceRequestStatus; + /** + * Property equipmentId + * @readonly + * + * @description + * Published equipment target or its explicit search scope. + * + * @access public + * @since unreleased + * + * @type {string} + */ + readonly equipmentId?: string; + /** + * Property siteId + * @readonly + * + * @description + * Root-site target or scope; qualification choices stay inside this site. + * + * @access public + * @since unreleased + * + * @type {string} + */ + readonly siteId?: string; +} +/** + * Type ServiceRequestCommand + * + * @description + * Explicit accepted request mutation; only conversion carries an idempotent work-link identity. + * + * @since unreleased + * + * @type {ServiceRequestCommand} + */ +export type ServiceRequestCommand = + | { + readonly kind: 'create'; + readonly organizationId: string; + readonly input: CreateServiceRequestInput; + } + | { + readonly kind: 'update'; + readonly organizationId: string; + readonly request: ServiceRequestOutput; + readonly input: UpdateServiceRequestInput; + } + | { + readonly kind: 'qualify'; + readonly organizationId: string; + readonly request: ServiceRequestOutput; + readonly input: QualifyServiceRequestInput; + } + | { + readonly kind: 'reject' | 'cancel'; + readonly organizationId: string; + readonly request: ServiceRequestOutput; + readonly input: DecisionServiceRequestInput; + } + | { + readonly kind: 'convert'; + readonly organizationId: string; + readonly request: ServiceRequestOutput; + readonly input: ConvertServiceRequestInput; + }; +/** + * Type ConversionCommand + * + * @description + * Captured conversion whose original revision and input survive an uncertain response. + * + * @since unreleased + * + * @type {ConversionCommand} + */ +type ConversionCommand = Extract; +/** + * Interface ServiceRequestState + * @interface + * + * @description + * Request collection, selected detail and accepted mutation states within one component lifetime. + * + * @since unreleased + */ +interface ServiceRequestState { + /** + * Property commandContext + * @readonly + * + * @description + * Account, session, organization and permissions captured by the completed journal restoration. + * + * @type {string | null} + */ + readonly commandContext: string | null; + /** + * Property commandCallState + * @readonly + * + * @description + * Durable conversion restoration must finish before another command can be accepted. + * + * @type {CallState} + */ + readonly commandCallState: CallState; + /** + * Property organizationId + * @readonly + * + * @description + * Organization authority shared by the owning route and server transport. + * + * @access public + * @since unreleased + * + * @type {string} + */ + readonly organizationId: string; + /** + * Property query + * @readonly + * + * @description + * Current authorized collection scope and pagination. + * + * @access public + * @since unreleased + * + * @type {ServiceRequestQuery | null} + */ + readonly query: ServiceRequestQuery | null; + /** + * Property selectedId + * @readonly + * + * @description + * Request whose detail data may be retained during a same-target refresh. + * + * @access public + * @since unreleased + * + * @type {string | null} + */ + readonly selectedId: string | null; + /** + * Property listCallState + * @readonly + * + * @description + * Collection request state; an unavailable read never implies an empty collection. + * + * @access public + * @since unreleased + * + * @type {CallState} + */ + readonly listCallState: CallState; + /** + * Property readCallState + * @readonly + * + * @description + * Selected request read state with same-target data retained on recoverable errors. + * + * @access public + * @since unreleased + * + * @type {CallState} + */ + readonly readCallState: CallState; + /** + * Property writeCallState + * @readonly + * + * @description + * Accepted mutation state, independent of cancellable reads. + * + * @access public + * @since unreleased + * + * @type {CallState} + */ + readonly writeCallState: CallState; + /** + * Property conversionCommand + * @readonly + * + * @description + * Exact accepted conversion retained for an uncertain-response replay. + * + * @access public + * @since unreleased + * + * @type {ConversionCommand | null} + */ + readonly conversionCommand: ConversionCommand | null; + /** + * Property total + * @readonly + * + * @description + * Authoritative count returned by the server for the current scope. + * + * @access public + * @since unreleased + * + * @type {number} + */ + readonly total: number; +} +/** + * Constant initialState + * + * @description + * Idle request state never claims that the server confirmed an empty collection. + * + * @since unreleased + */ +const initialState: ServiceRequestState = { + commandContext: null, + commandCallState: idleCallState(), + organizationId: '', + query: null, + selectedId: null, + total: 0, + listCallState: idleCallState(), + readCallState: idleCallState(), + writeCallState: idleCallState(), + conversionCommand: null, +}; +/** + * Constant ServiceRequestStore + * + * @description + * Request workflow with cancellable scoped reads, non-cancellable accepted writes and exact + * conversion replay. + * + * @since unreleased + */ +export const ServiceRequestStore = signalStore( + withEntities({ entity: type(), collection: 'request' }), + withState(initialState), + withComputed( + ( + store, + member = inject(ORGANIZATION_MEMBER_ACCESS_PORT), + session = inject(AUTH_SESSION_PORT), + ) => ({ + pageCount: computed(() => Math.max(1, Math.ceil(store.total() / 30))), + commandsReady: computed(() => { + const profile = member.profile(); + return ( + store.commandCallState().status === 'success' && + session.isAuthenticated() && + !!profile?.isActive && + profile.organizationId === store.organizationId() && + store.commandContext() === + JSON.stringify([ + profile.userId, + store.organizationId(), + session.sessionRevision(), + member.permissions(), + ]) + ); + }), + conversionUncertain: computed( + () => !!store.conversionCommand() && store.writeCallState().status !== 'pending', + ), + }), + ), + withMethods( + ( + store, + service = inject(ServiceRequestService), + dispatcher = inject(Dispatcher), + journal = inject(ServiceRequestCommandRepository), + conversion = inject(ServiceRequestConversionService), + member = inject(ORGANIZATION_MEMBER_ACCESS_PORT), + session = inject(AUTH_SESSION_PORT), + ) => { + const actor = (organizationId: string, permission: string) => { + const profile = member.profile(); + return journal.browser && + session.isAuthenticated() && + profile?.isActive && + profile.organizationId === organizationId && + member.permissions().includes(permission) + ? { userId: profile.userId, revision: session.sessionRevision() } + : null; + }; + const current = (organizationId: string, userId: string, revision: number): boolean => { + const profile = member.profile(); + return ( + session.isAuthenticated() && + session.sessionRevision() === revision && + !!profile?.isActive && + profile.userId === userId && + profile.organizationId === organizationId + ); + }; + const dispatchCommand = ( + command: ServiceRequestCommand, + ): Observable => { + if (command.kind === 'create') return service.create(command.organizationId, command.input); + switch (command.kind) { + case 'update': + return service.update(command.organizationId, command.request, command.input); + case 'qualify': + return service.qualify(command.organizationId, command.request, command.input); + case 'reject': + return service.reject(command.organizationId, command.request, command.input); + case 'cancel': + return service.cancel(command.organizationId, command.request, command.input); + case 'convert': + return service.convert(command.organizationId, command.request, command.input); + } + }; + const restoreCommands = rxMethod( + pipe( + switchMap(() => { + const organizationId = store.organizationId(); + const selectedId = store.selectedId(); + const profile = member.profile(); + const userId = profile?.userId ?? ''; + const revision = session.sessionRevision(); + const commandContext = JSON.stringify([ + userId, + organizationId, + revision, + member.permissions(), + ]); + if ( + !journal.browser || + !organizationId || + !profile || + !current(organizationId, userId, revision) + ) { + patchState(store, { commandCallState: idleCallState(), conversionCommand: null }); + return EMPTY; + } + patchState(store, { commandCallState: pendingCallState() }); + return from(journal.readPending(userId, organizationId)).pipe( + tapResponse({ + next: (commands) => { + if ( + !current(organizationId, userId, revision) || + store.organizationId() !== organizationId || + store.selectedId() !== selectedId + ) + return; + const retained = commands.find((command) => command.request.id === selectedId); + const active = store.writeCallState().status === 'pending'; + patchState(store, { + commandContext, + commandCallState: successCallState(commands), + ...(active + ? {} + : { + conversionCommand: retained + ? { + kind: retained.kind, + organizationId: retained.organizationId, + request: retained.request, + input: retained.input, + } + : null, + }), + }); + }, + error: (error: unknown) => { + if ( + current(organizationId, userId, revision) && + store.organizationId() === organizationId + ) + patchState(store, { commandCallState: errorCallState(toStoreError(error)) }); + }, + }), + ); + }), + ), + ); + return { + restoreCommands, + activateCommands(organizationId: string): void { + if (store.organizationId() !== organizationId) + patchState(store, { + organizationId, + selectedId: null, + commandCallState: idleCallState(), + conversionCommand: null, + }); + restoreCommands(); + }, + load: rxMethod( + pipe( + switchMap((query) => { + if (!query) { + patchState(store, initialState, removeAllEntities({ collection: 'request' }), { + writeCallState: + store.writeCallState().status === 'pending' + ? store.writeCallState() + : idleCallState(), + }); + return EMPTY; + } + const sameScope = JSON.stringify(store.query()) === JSON.stringify(query); + const sameOrganization = store.organizationId() === query.organizationId; + patchState( + store, + ...(sameOrganization + ? [] + : [initialState, removeAllEntities({ collection: 'request' })]), + { + organizationId: query.organizationId, + query, + total: sameScope ? store.total() : 0, + listCallState: pendingCallState(), + writeCallState: + store.writeCallState().status === 'pending' + ? store.writeCallState() + : sameOrganization + ? store.writeCallState() + : idleCallState(), + }, + ); + if (!sameOrganization) restoreCommands(); + const identity = actor( + query.organizationId, + ORGANIZATION_PERMISSION.SERVICE_REQUESTS_READ, + ); + if (!identity) { + patchState(store, { listCallState: idleCallState() }); + return EMPTY; + } + return service + .list(query.organizationId, { + page: query.page ?? 1, + itemsPerPage: 30, + search: query.search, + params: { + ...(query.status ? { status: query.status } : {}), + ...(query.equipmentId ? { equipmentId: query.equipmentId } : {}), + ...(query.siteId ? { siteId: query.siteId } : {}), + }, + }) + .pipe( + tapResponse({ + next: (collection) => { + if ( + !current(query.organizationId, identity.userId, identity.revision) || + !member + .permissions() + .includes(ORGANIZATION_PERMISSION.SERVICE_REQUESTS_READ) || + store.organizationId() !== query.organizationId + ) + return; + patchState( + store, + setAllEntities([...collection.member], { collection: 'request' }), + { total: collection.totalItems, listCallState: successCallState(null) }, + ); + }, + error: (error: unknown) => { + if ( + current(query.organizationId, identity.userId, identity.revision) && + store.organizationId() === query.organizationId + ) + patchState(store, { listCallState: errorCallState(toStoreError(error)) }); + }, + }), + ); + }), + ), + ), + read: rxMethod<{ readonly organizationId: string; readonly requestId: string } | null>( + pipe( + switchMap((target) => { + if (!target) { + patchState(store, { selectedId: null, readCallState: idleCallState() }); + return EMPTY; + } + const sameTarget = + store.organizationId() === target.organizationId && + store.selectedId() === target.requestId; + patchState(store, { + organizationId: target.organizationId, + selectedId: target.requestId, + readCallState: pendingCallState(sameTarget ? store.readCallState().data : null), + writeCallState: + store.writeCallState().status === 'pending' + ? store.writeCallState() + : sameTarget + ? store.writeCallState() + : idleCallState(), + }); + if (!sameTarget) restoreCommands(); + const identity = actor( + target.organizationId, + ORGANIZATION_PERMISSION.SERVICE_REQUESTS_READ, + ); + if (!identity) { + patchState(store, { readCallState: idleCallState() }); + return EMPTY; + } + return service.get(target.organizationId, target.requestId).pipe( + tapResponse({ + next: (request) => { + if ( + current(target.organizationId, identity.userId, identity.revision) && + member.permissions().includes(ORGANIZATION_PERMISSION.SERVICE_REQUESTS_READ) + ) + patchState(store, { readCallState: successCallState(request) }); + }, + error: (error: unknown) => { + if (!current(target.organizationId, identity.userId, identity.revision)) return; + patchState(store, { + readCallState: errorCallState( + toStoreError(error), + store.readCallState().data, + ), + }); + }, + }), + ); + }), + ), + ), + write: rxMethod( + pipe( + exhaustMap((incoming) => { + const retained = store.conversionCommand(); + const identity = actor( + incoming.organizationId, + incoming.kind === 'create' + ? ORGANIZATION_PERMISSION.SERVICE_REQUESTS_CREATE + : ORGANIZATION_PERMISSION.SERVICE_REQUESTS_MANAGE, + ); + if ( + !identity || + !store.commandsReady() || + store.organizationId() !== incoming.organizationId || + (retained && incoming.kind !== 'convert') + ) + return EMPTY; + const replay = + incoming.kind === 'convert' && + retained?.organizationId === incoming.organizationId && + retained.request.id === incoming.request.id; + if ( + incoming.kind === 'convert' && + !replay && + !member.permissions().includes(ORGANIZATION_PERMISSION.INTERVENTIONS_PLAN) + ) + return EMPTY; + const command = replay && retained ? retained : structuredClone(incoming); + patchState(store, { + organizationId: store.organizationId() || command.organizationId, + writeCallState: pendingCallState(), + ...(command.kind === 'convert' ? { conversionCommand: command } : {}), + }); + const accepted = + command.kind === 'convert' + ? conversion.execute({ ...command, userId: identity.userId }, identity.revision) + : dispatchCommand(command).pipe(shareReplay({ bufferSize: 1, refCount: false })); + return accepted.pipe( + tapResponse({ + next: (request) => { + if ( + !current(command.organizationId, identity.userId, identity.revision) || + store.organizationId() !== command.organizationId || + (command.kind !== 'create' && + store.selectedId() !== null && + store.selectedId() !== command.request.id) + ) { + patchState(store, { writeCallState: idleCallState() }); + return; + } + patchState(store, { + writeCallState: successCallState(request), + ...(store.selectedId() === request.id + ? { readCallState: successCallState(request) } + : {}), + ...(command.kind === 'convert' ? { conversionCommand: null } : {}), + }); + dispatcher.dispatch( + serviceRequestStoreEvents.saved({ + organizationId: command.organizationId, + request, + kind: command.kind, + }), + ); + }, + error: (error: unknown) => { + if ( + !current(command.organizationId, identity.userId, identity.revision) || + store.organizationId() !== command.organizationId || + (command.kind !== 'create' && + store.selectedId() !== null && + store.selectedId() !== command.request.id) + ) { + patchState(store, { writeCallState: idleCallState() }); + return; + } + const failure = toStoreError(error); + const code: number = Number(failure.code); + const rejected = code >= 400 && code < 500 && !failure.retryable; + patchState(store, { + writeCallState: errorCallState(failure), + ...(command.kind === 'convert' && + (rejected || (error instanceof ServiceRequestPersistenceError && !replay)) + ? { conversionCommand: null } + : {}), + }); + }, + }), + ); + }), + ), + ), + clearWrite(): void { + if (store.writeCallState().status === 'pending' || store.conversionUncertain()) return; + patchState(store, { writeCallState: idleCallState(), conversionCommand: null }); + }, + clearSession(): void { + patchState( + store, + { + commandContext: null, + readCallState: idleCallState(), + listCallState: idleCallState(), + writeCallState: idleCallState(), + commandCallState: idleCallState(), + conversionCommand: null, + total: 0, + }, + removeAllEntities({ collection: 'request' }), + ); + }, + }; + }, + ), + withHooks( + ( + store, + member = inject(ORGANIZATION_MEMBER_ACCESS_PORT), + session = inject(AUTH_SESSION_PORT), + ) => ({ + onInit(): void { + let previousRevision = session.sessionRevision(); + let previousUserId = member.profile()?.userId ?? null; + effect(() => { + const organizationId = store.organizationId(); + store.selectedId(); + const profile = member.profile(); + const revision = session.sessionRevision(); + const authenticated = session.isAuthenticated(); + member.permissions(); + const replaced = + previousRevision !== revision || + (previousUserId !== null && profile?.userId !== previousUserId); + previousRevision = revision; + if (profile) previousUserId = profile.userId; + untracked(() => { + if ( + replaced || + !authenticated || + (organizationId && profile?.organizationId !== organizationId) + ) + store.clearSession(); + store.restoreCommands(); + }); + }); + }, + }), + ), +); +/** + * Type ServiceRequestStoreType + * + * @description + * Store instance contract consumed by owning pages and tests. + * + * @since unreleased + * + * @type {ServiceRequestStoreType} + */ +export type ServiceRequestStoreType = InstanceType; diff --git a/src/app/features/organization/features/service-requests/state/service-request/testing/service-request-context.fixture.ts b/src/app/features/organization/features/service-requests/state/service-request/testing/service-request-context.fixture.ts new file mode 100644 index 000000000..569779eb9 --- /dev/null +++ b/src/app/features/organization/features/service-requests/state/service-request/testing/service-request-context.fixture.ts @@ -0,0 +1,110 @@ +import { signal, type Provider, type Signal, type WritableSignal } from '@angular/core'; +import { vi, type Mock } from 'vitest'; +import { AUTH_SESSION_PORT } from '@features/auth/ports'; +import { ServiceRequestCommandRepository } from '@features/organization/features/service-requests/data-access'; +import type { ServiceRequestConversionCommand } from '@features/organization/features/service-requests/models'; +import { + ORGANIZATION_PERMISSION, + type CurrentOrganizationMemberProfileOutput, +} from '@features/organization/models'; +import { ORGANIZATION_MEMBER_ACCESS_PORT } from '@features/organization/ports/organization-member-access'; + +/** + * Function serviceRequestContext + * + * @description + * Creates isolated actor/session signals and an immutable journal mock for store and page tests. + * + * @access public + * @since unreleased + * + * @param {Signal} grants - Reactive permission projection for the active actor. + * + * @returns {object} Mutable fixture signals, journal records and Angular provider overrides. + * + * @function serviceRequestContext + */ +export function serviceRequestContext( + grants: Signal = signal([ + ORGANIZATION_PERMISSION.SERVICE_REQUESTS_READ, + ORGANIZATION_PERMISSION.SERVICE_REQUESTS_CREATE, + ORGANIZATION_PERMISSION.SERVICE_REQUESTS_MANAGE, + ORGANIZATION_PERMISSION.INTERVENTIONS_PLAN, + ]), +): { + profile: WritableSignal; + revision: WritableSignal; + authenticated: WritableSignal; + grants: Signal; + commands: Map; + journal: { + browser: boolean; + sessionRevision: Mock<() => number>; + isCurrent: Mock<(userId: string, organizationId: string, captured: number) => boolean>; + readPending: Mock< + (userId: string, organizationId: string) => Promise + >; + retain: Mock<(command: ServiceRequestConversionCommand, captured: number) => Promise>; + acknowledge: Mock< + (command: ServiceRequestConversionCommand, captured: number) => Promise + >; + }; + providers: Provider[]; +} { + const profile = signal({ + '@id': '/me', + '@type': 'OrganizationMember', + id: 'member', + userId: 'user', + organizationId: 'org', + isActive: true, + joinedAt: '2026-10-08T00:00:00Z', + roles: [], + permissions: [], + }); + const revision = signal(1); + const authenticated = signal(true); + const commands = new Map(); + const current = (userId: string, organizationId: string, captured: number): boolean => + authenticated() && + revision() === captured && + profile()?.userId === userId && + profile()?.organizationId === organizationId && + profile()?.isActive === true && + grants().includes(ORGANIZATION_PERMISSION.SERVICE_REQUESTS_MANAGE); + const journal = { + browser: true, + sessionRevision: vi.fn(() => revision()), + isCurrent: vi.fn(current), + readPending: vi.fn(async (userId: string, organizationId: string) => + current(userId, organizationId, revision()) + ? [...commands.values()] + .filter( + (command) => command.userId === userId && command.organizationId === organizationId, + ) + .map((command) => structuredClone(command)) + : [], + ), + retain: vi.fn(async (command: ServiceRequestConversionCommand, captured: number) => { + if (!current(command.userId, command.organizationId, captured)) + throw new Error('Ownership changed'); + const previous = commands.get(command.input.clientOperationId); + if (previous && JSON.stringify(previous) !== JSON.stringify(command)) + throw new Error('Different intention'); + commands.set(command.input.clientOperationId, structuredClone(command)); + }), + acknowledge: vi.fn(async (command: ServiceRequestConversionCommand, captured: number) => { + if (current(command.userId, command.organizationId, captured)) + commands.delete(command.input.clientOperationId); + }), + }; + const providers: Provider[] = [ + { + provide: AUTH_SESSION_PORT, + useValue: { sessionRevision: revision, isAuthenticated: authenticated }, + }, + { provide: ORGANIZATION_MEMBER_ACCESS_PORT, useValue: { profile, permissions: grants } }, + { provide: ServiceRequestCommandRepository, useValue: journal }, + ]; + return { profile, revision, authenticated, grants, commands, journal, providers }; +} diff --git a/src/app/features/organization/features/service-requests/state/service-request/testing/service-request.store.spec.ts b/src/app/features/organization/features/service-requests/state/service-request/testing/service-request.store.spec.ts new file mode 100644 index 000000000..9e2eaeadc --- /dev/null +++ b/src/app/features/organization/features/service-requests/state/service-request/testing/service-request.store.spec.ts @@ -0,0 +1,249 @@ +import { HttpErrorResponse } from '@angular/common/http'; +import { TestBed } from '@angular/core/testing'; +import { of, Subject, throwError } from 'rxjs'; +import type { HydraCollection } from '@core/api/models'; +import { ServiceRequestService } from '@features/organization/features/service-requests/data-access'; +import type { ServiceRequestOutput } from '@features/organization/features/service-requests/models'; +import { serviceRequestFixture } from '@features/organization/features/service-requests/models/service-request/testing/service-request.fixture'; +import { ServiceRequestStore, type ServiceRequestStoreType } from '../service-request.store'; +import { serviceRequestContext } from './service-request-context.fixture'; +describe('ServiceRequestStore', () => { + const request = serviceRequestFixture(); + const collection: HydraCollection = { + '@id': 'requests', + '@type': 'Collection', + member: [request], + totalItems: 61, + }; + let service: Record< + 'list' | 'get' | 'create' | 'update' | 'qualify' | 'reject' | 'cancel' | 'convert', + ReturnType + >; + let store: ServiceRequestStoreType; + let context: ReturnType; + beforeEach(async () => { + context = serviceRequestContext(); + service = { + list: vi.fn().mockReturnValue(of(collection)), + get: vi.fn().mockReturnValue(of(request)), + create: vi.fn().mockReturnValue(of(request)), + update: vi.fn().mockReturnValue(of(request)), + qualify: vi.fn().mockReturnValue(of(request)), + reject: vi.fn().mockReturnValue(of(request)), + cancel: vi.fn().mockReturnValue(of(request)), + convert: vi.fn().mockReturnValue(of(request)), + }; + TestBed.configureTestingModule({ + providers: [ + ...context.providers, + ServiceRequestStore, + { provide: ServiceRequestService, useValue: service }, + ], + }); + store = TestBed.inject(ServiceRequestStore); + store.activateCommands('org'); + TestBed.tick(); + await vi.waitFor(() => expect(store.commandsReady()).toBe(true)); + }); + it('cancels an old target search and uses server totals for every page', () => { + const old = new Subject>(); + service.list.mockReturnValueOnce(old); + store.load({ organizationId: 'org', equipmentId: 'old', search: 'broken' }); + store.load({ organizationId: 'org', siteId: 'site', status: 'qualified', page: 2 }); + expect(old.observed).toBe(false); + expect(store.requestEntities()).toEqual([request]); + expect(store.pageCount()).toBe(3); + expect(service.list).toHaveBeenLastCalledWith( + 'org', + expect.objectContaining({ page: 2, params: { siteId: 'site', status: 'qualified' } }), + ); + }); + it('keeps accepted writes in flight and ignores a repeated submission', async () => { + const accepted = new Subject(); + service.qualify.mockReturnValue(accepted); + store.read({ organizationId: 'org', requestId: 'request' }); + await vi.waitFor(() => expect(store.commandsReady()).toBe(true)); + store.write({ kind: 'qualify', organizationId: 'org', request, input: { note: 'Inspect' } }); + store.write({ kind: 'qualify', organizationId: 'org', request, input: { note: 'Again' } }); + expect(service.qualify).toHaveBeenCalledTimes(1); + expect(accepted.observed).toBe(true); + accepted.next(serviceRequestFixture({ status: 'qualified', revision: 2 })); + accepted.complete(); + expect(store.readCallState().data?.status).toBe('qualified'); + expect(store.writeCallState().status).toBe('success'); + }); + it('retains the exact conversion after network loss even if a caller provides another operation or work', async () => { + const original = { + clientOperationId: 'original', + existingInterventionId: 'work', + existingTaskId: 'task', + }; + const qualified = serviceRequestFixture({ status: 'qualified', revision: 3 }); + service.convert + .mockReturnValueOnce(throwError(() => new HttpErrorResponse({ status: 0 }))) + .mockReturnValueOnce( + of( + serviceRequestFixture({ + status: 'converted', + revision: 4, + interventionId: 'work', + taskId: 'task', + }), + ), + ); + store.read({ organizationId: 'org', requestId: 'request' }); + await vi.waitFor(() => expect(store.commandsReady()).toBe(true)); + store.write({ kind: 'convert', organizationId: 'org', request: qualified, input: original }); + await vi.waitFor(() => expect(store.conversionUncertain()).toBe(true)); + store.clearWrite(); + expect(store.conversionCommand()?.input).toEqual(original); + store.write({ + kind: 'convert', + organizationId: 'org', + request: serviceRequestFixture({ revision: 99 }), + input: { clientOperationId: 'replacement' }, + }); + await vi.waitFor(() => expect(store.writeCallState().status).toBe('success')); + expect(service.convert).toHaveBeenLastCalledWith('org', qualified, original); + expect(store.conversionCommand()).toBeNull(); + expect(store.readCallState().data?.interventionId).toBe('work'); + }); + it.each(['session', 'account'])( + 'refuses an old uncertain conversion immediately after %s replacement before effects run', + async (replacement: string) => { + service.convert.mockReturnValue(throwError(() => new HttpErrorResponse({ status: 0 }))); + store.read({ organizationId: 'org', requestId: 'request' }); + await vi.waitFor(() => expect(store.commandsReady()).toBe(true)); + const command = { + kind: 'convert' as const, + organizationId: 'org', + request, + input: { + clientOperationId: 'old-session-operation', + existingInterventionId: 'old-work', + existingTaskId: 'old-task', + }, + }; + store.write(command); + await vi.waitFor(() => expect(store.conversionUncertain()).toBe(true)); + if (replacement === 'account') { + const profile = context.profile(); + if (!profile) throw new Error('Missing active fixture member'); + context.profile.set({ ...profile, userId: 'new-user' }); + } else context.revision.set(2); + expect(store.commandsReady()).toBe(false); + store.write(command); + expect(service.convert).toHaveBeenCalledOnce(); + expect(context.journal.retain).toHaveBeenCalledOnce(); + }, + ); + it('keeps a local storage failure editable instead of claiming an unresolved durable conversion', async () => { + context.journal.retain.mockRejectedValue(new DOMException('Quota full', 'QuotaExceededError')); + store.read({ organizationId: 'org', requestId: 'request' }); + await vi.waitFor(() => expect(store.commandsReady()).toBe(true)); + store.write({ + kind: 'convert', + organizationId: 'org', + request, + input: { + clientOperationId: 'not-accepted', + existingInterventionId: 'work', + existingTaskId: 'task', + }, + }); + await vi.waitFor(() => expect(store.writeCallState().status).toBe('error')); + expect(service.convert).not.toHaveBeenCalled(); + expect(context.commands.size).toBe(0); + expect(store.conversionUncertain()).toBe(false); + expect(store.conversionCommand()).toBeNull(); + expect(store.readCallState().data).toEqual(request); + expect(store.writeCallState().error?.message).toContain('Your draft has been kept'); + }); + it('keeps a previously uncertain conversion locked when exact retry cannot access local storage', async () => { + service.convert.mockReturnValue(throwError(() => new HttpErrorResponse({ status: 0 }))); + store.read({ organizationId: 'org', requestId: 'request' }); + await vi.waitFor(() => expect(store.commandsReady()).toBe(true)); + const command = { + kind: 'convert' as const, + organizationId: 'org', + request, + input: { + clientOperationId: 'already-accepted', + existingInterventionId: 'work', + existingTaskId: 'task', + }, + }; + store.write(command); + await vi.waitFor(() => expect(store.conversionUncertain()).toBe(true)); + context.journal.retain.mockRejectedValue(new DOMException('Storage unavailable', 'AbortError')); + store.write(command); + await vi.waitFor(() => expect(store.writeCallState().status).toBe('error')); + expect(service.convert).toHaveBeenCalledOnce(); + expect(context.journal.acknowledge).not.toHaveBeenCalled(); + expect(store.conversionUncertain()).toBe(true); + expect(store.conversionCommand()).toEqual(command); + expect(context.commands.get('already-accepted')?.input).toEqual(command.input); + }); + it('keeps a stale revision error until explicit review instead of automatically rebasing an action', async () => { + service.update.mockReturnValue( + throwError( + () => + new HttpErrorResponse({ + status: 412, + error: { detail: 'Review current version' }, + }), + ), + ); + store.read({ organizationId: 'org', requestId: 'request' }); + await vi.waitFor(() => expect(store.commandsReady()).toBe(true)); + store.write({ kind: 'update', organizationId: 'org', request, input: { title: 'Draft' } }); + expect(store.writeCallState().error?.code).toBe(412); + expect(store.readCallState().data?.revision).toBe(1); + expect(service.get).toHaveBeenCalledTimes(1); + store.clearWrite(); + expect(store.writeCallState().status).toBe('idle'); + }); + it('does not cancel a previous organization write or show its failure in the new scope', () => { + const accepted = new Subject(); + service.create.mockReturnValue(accepted); + store.load({ organizationId: 'org' }); + store.write({ + kind: 'create', + organizationId: 'org', + input: { title: 'Repair', description: 'Damaged gauge', equipmentId: 'equipment' }, + }); + store.load({ organizationId: 'other' }); + expect(accepted.observed).toBe(true); + accepted.error({ status: 409, title: 'Conflict', detail: 'Old organization' }); + expect(store.writeCallState().status).toBe('idle'); + expect(store.organizationId()).toBe('other'); + }); + it('cancels obsolete detail reads and clears the retained target when leaving', () => { + const old = new Subject(); + service.get.mockReturnValueOnce(old); + store.read({ organizationId: 'org', requestId: 'old' }); + store.read({ organizationId: 'org', requestId: 'request' }); + expect(old.observed).toBe(false); + expect(store.readCallState().data?.id).toBe('request'); + store.read(null); + expect(store.readCallState().data).toBeNull(); + }); + it('keeps old request action feedback out of another request in the same organization', async () => { + const accepted = new Subject(); + service.cancel.mockReturnValue(accepted); + store.read({ organizationId: 'org', requestId: 'request' }); + await vi.waitFor(() => expect(store.commandsReady()).toBe(true)); + store.write({ + kind: 'cancel', + organizationId: 'org', + request, + input: { reason: 'Resolved elsewhere' }, + }); + service.get.mockReturnValue(of(serviceRequestFixture({ id: 'other' }))); + store.read({ organizationId: 'org', requestId: 'other' }); + accepted.next(serviceRequestFixture({ status: 'cancelled' })); + accepted.complete(); + expect(store.readCallState().data?.id).toBe('other'); + expect(store.writeCallState().status).toBe('idle'); + }); +}); diff --git a/src/app/features/organization/features/service-requests/ui/components/index.ts b/src/app/features/organization/features/service-requests/ui/components/index.ts new file mode 100644 index 000000000..a0a0dc17a --- /dev/null +++ b/src/app/features/organization/features/service-requests/ui/components/index.ts @@ -0,0 +1,2 @@ +export { ServiceRequestEquipmentPicker } from './service-request-equipment-picker/service-request-equipment-picker.component'; +export { ServiceRequestSitePicker } from './service-request-site-picker/service-request-site-picker.component'; diff --git a/src/app/features/organization/features/service-requests/ui/components/service-request-equipment-picker/service-request-equipment-picker.component.html b/src/app/features/organization/features/service-requests/ui/components/service-request-equipment-picker/service-request-equipment-picker.component.html new file mode 100644 index 000000000..c2302fdee --- /dev/null +++ b/src/app/features/organization/features/service-requests/ui/components/service-request-equipment-picker/service-request-equipment-picker.component.html @@ -0,0 +1,85 @@ + + + +
+ No equipment selected + @for (equipment of store.equipmentEntities(); track equipment.id) { + + + {{ title(equipment) }} + @if (equipment.facilityName) { + {{ equipment.facilityName }} + } + @if (!selectable(equipment)) { + Unavailable for a new request + } + + + } +
+
+
+
+ + {{ page() }} / {{ store.equipmentPageCount() }} + +
+@if (!canRead()) { +

+ Equipment selection requires permission to read the park. +

+} +@if (store.equipmentCallState().error; as error) { + + +} +@if (store.selectedEquipmentCallState().error; as error) { + +} diff --git a/src/app/features/organization/features/service-requests/ui/components/service-request-equipment-picker/service-request-equipment-picker.component.ts b/src/app/features/organization/features/service-requests/ui/components/service-request-equipment-picker/service-request-equipment-picker.component.ts new file mode 100644 index 000000000..902e2f7d3 --- /dev/null +++ b/src/app/features/organization/features/service-requests/ui/components/service-request-equipment-picker/service-request-equipment-picker.component.ts @@ -0,0 +1,373 @@ +import { isPlatformBrowser } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + PLATFORM_ID, + computed, + effect, + inject, + input, + model, + signal, + untracked, +} from '@angular/core'; +import type { FormValueControl } from '@angular/forms/signals'; +import { OrganizationPermissionService } from '@features/organization/access'; +import { + EquipmentTypeCatalogStore, + buildEquipmentTitle, +} from '@features/organization/features/equipments'; +import type { EquipmentOutput } from '@features/organization/features/equipments/models'; +import { ServiceRequestTargetStore } from '@features/organization/features/service-requests/state'; +import { ORGANIZATION_PERMISSION } from '@features/organization/models'; +import { HlmButton } from '@shared/ui/button'; +import { HlmComboboxImports } from '@shared/ui/combobox'; +/** + * Class ServiceRequestEquipmentPicker + * @class ServiceRequestEquipmentPicker + * + * @description + * Browser-only published-equipment chooser with site subtree scope and retained-selection + * hydration. + * + * @since unreleased + */ +@Component({ + selector: 'app-service-request-equipment-picker', + templateUrl: './service-request-equipment-picker.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ServiceRequestTargetStore, EquipmentTypeCatalogStore], + imports: [HlmButton, ...HlmComboboxImports], +}) +export class ServiceRequestEquipmentPicker implements FormValueControl { + //#region Properties + /** + * Property organizationId + * @readonly + * + * @description + * Organization authority shared by the owning route and server transport. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly organizationId = input.required(); + /** + * Property siteId + * @readonly + * + * @description + * Root-site target or scope; qualification choices stay inside this site. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly siteId = input(''); + /** + * Property inputId + * @readonly + * + * @description + * Associates the native label with the picker input. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly inputId = input('service-request-equipment'); + /** + * Property value + * @readonly + * + * @description + * Opaque target identity; the empty string represents no selection. + * + * @access public + * @since unreleased + * + * @type {ModelSignal} + */ + public readonly value = model(''); + /** + * Property disabled + * @readonly + * + * @description + * Disables target changes without erasing their retained label. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly disabled = input(false); + /** + * Property store + * @readonly + * + * @description + * Component-owned request workflow state. + * + * @access protected + * @since unreleased + * + * @type {ServiceRequestStoreType} + */ + protected readonly store = inject(ServiceRequestTargetStore); + /** + * Property catalogue + * @readonly + * + * @description + * Authorized catalogue labels including retained historical equipment types. + * + * @access protected + * @since unreleased + * + * @type {EquipmentTypeCatalogStoreType} + */ + protected readonly catalogue = inject(EquipmentTypeCatalogStore); + /** + * Property platformId + * @readonly + * + * @description + * Browser guard for secondary authenticated reads. + * + * @access private + * @since unreleased + * + * @type {object} + */ + private readonly platformId = inject(PLATFORM_ID); + /** + * Property permissions + * @readonly + * + * @description + * API-derived organization permission authority. + * + * @access private + * @since unreleased + * + * @type {OrganizationPermissionService} + */ + private readonly permissions = inject(OrganizationPermissionService); + /** + * Property canRead + * @readonly + * + * @description + * Permission gate for the owned resource reads. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly canRead = computed(() => + this.permissions.hasPermission(ORGANIZATION_PERMISSION.EQUIPMENT_READ), + ); + /** + * Property searchTerm + * @readonly + * + * @description + * Remote picker search; filtering never silently changes server pagination. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal} + */ + protected readonly searchTerm = signal(''); + /** + * Property page + * @readonly + * + * @description + * Current server page, starting at one. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal} + */ + protected readonly page = signal(1); + /** + * Property placeholder + * @readonly + * + * @description + * Localized accessible target-choice prompt. + * + * @access protected + * @since unreleased + * + * @type {string | undefined} + */ + protected readonly placeholder = $localize`:@@serviceRequest.target.equipmentPlaceholder:Choose equipment`; + /** + * Property labelOf + * @readonly + * + * @description + * Retained-selection label hydrated independently of current search results. + * + * @access protected + * @since unreleased + * + * @type {(value: unknown) => string} + */ + protected readonly labelOf = (value: unknown): string => { + if (typeof value !== 'string' || !value) + return $localize`:@@serviceRequest.target.noEquipment:No equipment selected`; + const selected = this.store.selectedEquipmentCallState().data; + const record = + selected?.id === value + ? selected + : this.store.equipmentEntities().find((entry) => entry.id === value); + return record ? this.title(record) : value; + }; + /** + * Property remoteFilter + * @readonly + * + * @description + * Server-owned candidate search keeps authorized page entries visible. + * + * @access protected + * @since unreleased + * + * @type {() => boolean} + */ + protected readonly remoteFilter = (): boolean => true; + //#endregion + + //#region Constructor + /** + * Constructor + * @constructor + * + * @description + * Connects browser-only candidate search, catalogue labels and independent retained-selection + * reads. + * + * @access public + * @since unreleased + */ + public constructor() { + effect(() => { + this.organizationId(); + this.siteId(); + untracked(() => { + this.page.set(1); + this.searchTerm.set(''); + }); + }); + effect(() => { + const organizationId = this.organizationId(), + siteId = this.siteId(), + page = this.page(), + search = this.searchTerm(); + const enabled = organizationId && this.canRead() && isPlatformBrowser(this.platformId); + untracked(() => { + this.store.loadEquipment( + enabled ? { organizationId, siteId: siteId || undefined, page, search } : null, + ); + if (enabled && this.catalogue.organizationId() !== organizationId) + this.catalogue.load(organizationId); + }); + }); + effect(() => { + const organizationId = this.organizationId(), + equipmentId = this.value(); + const enabled = + equipmentId && organizationId && this.canRead() && isPlatformBrowser(this.platformId); + untracked(() => this.store.readEquipment(enabled ? { organizationId, equipmentId } : null)); + }); + } + //#endregion + + //#region Methods + /** + * Method title + * + * @description + * Builds the equipment title with its authorized catalogue label. + * + * @access protected + * @since unreleased + * + * @param {EquipmentOutput} equipment - Authorized published equipment projection. + * + * @returns {string} Localized human-readable request value. + */ + protected title(equipment: EquipmentOutput): string { + return buildEquipmentTitle( + equipment, + this.catalogue.options().find((entry) => entry.value === equipment.type)?.label, + ); + } + /** + * Method selectable + * + * @description + * Retired and draft equipment remain readable but cannot be newly chosen. + * + * @access protected + * @since unreleased + * + * @param {EquipmentOutput} equipment - Authorized published equipment projection. + * + * @returns {boolean} Result owned by the request workflow. + */ + protected selectable(equipment: EquipmentOutput): boolean { + return equipment.status !== 'decommissioned' && equipment.recordStatus !== 'draft'; + } + /** + * Method pick + * + * @description + * Accepts only an authorized current candidate, or an explicit empty selection. + * + * @access protected + * @since unreleased + * + * @param {unknown} value - Native choice value validated against the current options. + * + * @returns {void} Result owned by the request workflow. + */ + protected pick(value: unknown): void { + if (this.disabled() || !this.canRead() || typeof value !== 'string') return; + if (!value) { + this.value.set(''); + return; + } + const candidate = this.store.equipmentEntities().find((entry) => entry.id === value); + if (candidate && this.selectable(candidate)) this.value.set(value); + } + /** + * Method search + * + * @description + * Commits remote target search and returns pagination to its first page. + * + * @access protected + * @since unreleased + * + * @param {string} value - Native choice value validated against the current options. + * + * @returns {void} Result owned by the request workflow. + */ + protected search(value: string): void { + this.page.set(1); + this.searchTerm.set(value); + } + //#endregion +} diff --git a/src/app/features/organization/features/service-requests/ui/components/service-request-equipment-picker/testing/service-request-equipment-picker.component.spec.ts b/src/app/features/organization/features/service-requests/ui/components/service-request-equipment-picker/testing/service-request-equipment-picker.component.spec.ts new file mode 100644 index 000000000..6def09e40 --- /dev/null +++ b/src/app/features/organization/features/service-requests/ui/components/service-request-equipment-picker/testing/service-request-equipment-picker.component.spec.ts @@ -0,0 +1,78 @@ +import { PLATFORM_ID } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { of } from 'rxjs'; +import { OrganizationPermissionService } from '@features/organization/access'; +import { + EquipmentService, + EquipmentTypeService, +} from '@features/organization/features/equipments/data-access'; +import type { EquipmentOutput } from '@features/organization/features/equipments/models'; +import { FacilityService } from '@features/organization/features/facilities/data-access'; +import { ServiceRequestEquipmentPicker } from '../service-request-equipment-picker.component'; +describe('ServiceRequestEquipmentPicker', () => { + beforeAll(() => { + globalThis.ResizeObserver ??= class { + observe(): void {} + unobserve(): void {} + disconnect(): void {} + } as unknown as typeof ResizeObserver; + }); + const current = { + id: 'current', + organizationId: 'org', + name: 'Reserve extinguisher', + assetCode: 'FIRE-1', + type: 'fire_extinguisher', + status: 'in_stock', + recordStatus: 'published', + } as unknown as EquipmentOutput; + it('does not read secondary equipment or catalogue data during SSR', async () => { + const equipment = { list: vi.fn(), listByFacility: vi.fn(), get: vi.fn() }, + catalogue = { listAll: vi.fn() }; + TestBed.configureTestingModule({ + imports: [ServiceRequestEquipmentPicker], + providers: [ + { provide: PLATFORM_ID, useValue: 'server' }, + { provide: EquipmentService, useValue: equipment }, + { provide: EquipmentTypeService, useValue: catalogue }, + { provide: FacilityService, useValue: {} }, + { provide: OrganizationPermissionService, useValue: { hasPermission: () => true } }, + ], + }); + const fixture = TestBed.createComponent(ServiceRequestEquipmentPicker); + fixture.componentRef.setInput('organizationId', 'org'); + fixture.componentRef.setInput('value', 'current'); + await fixture.whenStable(); + expect(equipment.list).not.toHaveBeenCalled(); + expect(equipment.get).not.toHaveBeenCalled(); + expect(catalogue.listAll).not.toHaveBeenCalled(); + }); + it('hydrates a selection outside the search page and refuses retired or draft new assignments', async () => { + const retired = { ...current, id: 'retired', status: 'decommissioned' }, + draft = { ...current, id: 'draft', recordStatus: 'draft' }; + const equipment = { + list: vi.fn().mockReturnValue(of({ member: [retired, draft], totalItems: 2 })), + listByFacility: vi.fn(), + get: vi.fn().mockReturnValue(of(current)), + }; + TestBed.configureTestingModule({ + imports: [ServiceRequestEquipmentPicker], + providers: [ + { provide: EquipmentService, useValue: equipment }, + { provide: EquipmentTypeService, useValue: { listAll: vi.fn().mockReturnValue(of([])) } }, + { provide: FacilityService, useValue: {} }, + { provide: OrganizationPermissionService, useValue: { hasPermission: () => true } }, + ], + }); + const fixture = TestBed.createComponent(ServiceRequestEquipmentPicker); + fixture.componentRef.setInput('organizationId', 'org'); + fixture.componentRef.setInput('value', 'current'); + await fixture.whenStable(); + expect(fixture.componentInstance['labelOf']('current')).toContain('Reserve extinguisher'); + fixture.componentInstance['pick']('retired'); + fixture.componentInstance['pick']('draft'); + expect(fixture.componentInstance.value()).toBe('current'); + fixture.componentInstance['pick'](''); + expect(fixture.componentInstance.value()).toBe(''); + }); +}); diff --git a/src/app/features/organization/features/service-requests/ui/components/service-request-site-picker/service-request-site-picker.component.html b/src/app/features/organization/features/service-requests/ui/components/service-request-site-picker/service-request-site-picker.component.html new file mode 100644 index 000000000..0343e2800 --- /dev/null +++ b/src/app/features/organization/features/service-requests/ui/components/service-request-site-picker/service-request-site-picker.component.html @@ -0,0 +1,21 @@ + +@if (!canRead()) { +

+ Site selection requires permission to read sites. +

+} +@if (store.selectedSiteCallState().error; as error) { + +} diff --git a/src/app/features/organization/features/service-requests/ui/components/service-request-site-picker/service-request-site-picker.component.ts b/src/app/features/organization/features/service-requests/ui/components/service-request-site-picker/service-request-site-picker.component.ts new file mode 100644 index 000000000..814abb895 --- /dev/null +++ b/src/app/features/organization/features/service-requests/ui/components/service-request-site-picker/service-request-site-picker.component.ts @@ -0,0 +1,226 @@ +import { isPlatformBrowser } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + PLATFORM_ID, + computed, + effect, + inject, + input, + model, + signal, + untracked, +} from '@angular/core'; +import type { FormValueControl } from '@angular/forms/signals'; +import { OrganizationPermissionService } from '@features/organization/access'; +import { FacilityOptionPicker } from '@features/organization/features/facilities/ui/components'; +import { ServiceRequestTargetStore } from '@features/organization/features/service-requests/state'; +import { ORGANIZATION_PERMISSION } from '@features/organization/models'; +/** + * Class ServiceRequestSitePicker + * @class ServiceRequestSitePicker + * + * @description + * Browser-only root-site chooser reusing the Facility-owned native picker. + * + * @since unreleased + */ +@Component({ + selector: 'app-service-request-site-picker', + templateUrl: './service-request-site-picker.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ServiceRequestTargetStore], + imports: [FacilityOptionPicker], +}) +export class ServiceRequestSitePicker implements FormValueControl { + //#region Properties + /** + * Property organizationId + * @readonly + * + * @description + * Organization authority shared by the owning route and server transport. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly organizationId = input.required(); + /** + * Property inputId + * @readonly + * + * @description + * Associates the native label with the picker input. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly inputId = input('service-request-site'); + /** + * Property value + * @readonly + * + * @description + * Opaque target identity; the empty string represents no selection. + * + * @access public + * @since unreleased + * + * @type {ModelSignal} + */ + public readonly value = model(''); + /** + * Property disabled + * @readonly + * + * @description + * Disables target changes without erasing their retained label. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly disabled = input(false); + /** + * Property store + * @readonly + * + * @description + * Component-owned request workflow state. + * + * @access protected + * @since unreleased + * + * @type {ServiceRequestStoreType} + */ + protected readonly store = inject(ServiceRequestTargetStore); + /** + * Property platformId + * @readonly + * + * @description + * Browser guard for secondary authenticated reads. + * + * @access private + * @since unreleased + * + * @type {object} + */ + private readonly platformId = inject(PLATFORM_ID); + /** + * Property permissions + * @readonly + * + * @description + * API-derived organization permission authority. + * + * @access private + * @since unreleased + * + * @type {OrganizationPermissionService} + */ + private readonly permissions = inject(OrganizationPermissionService); + /** + * Property canRead + * @readonly + * + * @description + * Permission gate for the owned resource reads. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly canRead = computed(() => + this.permissions.hasPermission(ORGANIZATION_PERMISSION.FACILITIES_READ), + ); + /** + * Property searchTerm + * @readonly + * + * @description + * Remote picker search; filtering never silently changes server pagination. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal} + */ + protected readonly searchTerm = signal(''); + /** + * Property page + * @readonly + * + * @description + * Current server page, starting at one. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal} + */ + protected readonly page = signal(1); + //#endregion + + //#region Constructor + /** + * Constructor + * @constructor + * + * @description + * Connects browser-only root-site search and independent retained-selection hydration. + * + * @access public + * @since unreleased + */ + public constructor() { + effect(() => { + this.organizationId(); + untracked(() => { + this.page.set(1); + this.searchTerm.set(''); + }); + }); + effect(() => { + const organizationId = this.organizationId(), + page = this.page(), + search = this.searchTerm(); + const enabled = organizationId && this.canRead() && isPlatformBrowser(this.platformId); + untracked(() => this.store.loadSites(enabled ? { organizationId, page, search } : null)); + }); + effect(() => { + const organizationId = this.organizationId(), + siteId = this.value(); + const enabled = + siteId && organizationId && this.canRead() && isPlatformBrowser(this.platformId); + untracked(() => this.store.readSite(enabled ? { organizationId, siteId } : null)); + }); + } + //#endregion + + //#region Methods + /** + * Method search + * + * @description + * Commits remote target search and returns pagination to its first page. + * + * @access protected + * @since unreleased + * + * @param {string} value - Native choice value validated against the current options. + * + * @returns {void} Result owned by the request workflow. + */ + protected search(value: string): void { + this.page.set(1); + this.searchTerm.set(value); + } + //#endregion +} diff --git a/src/app/features/organization/features/service-requests/ui/forms/service-request-action-form/service-request-action-form.component.html b/src/app/features/organization/features/service-requests/ui/forms/service-request-action-form/service-request-action-form.component.html new file mode 100644 index 000000000..0f456a46d --- /dev/null +++ b/src/app/features/organization/features/service-requests/ui/forms/service-request-action-form/service-request-action-form.component.html @@ -0,0 +1,153 @@ +
+ @if (kind() === 'qualify') { + @if (!request().equipmentId) { +
+ + + @if (actionForm().touched()) { + @for (error of actionForm.equipmentId().errors(); track error) { + {{ error.message }} + } + } +
+ } +
+ + + @if (actionForm.note().touched()) { + @for (error of actionForm.note().errors(); track error) { + {{ error.message }} + } + } +
+ } + @if (kind() === 'reject' || kind() === 'cancel') { +
+ + + @if (actionForm().touched()) { + @for (error of actionForm.reason().errors(); track error) { + {{ error.message }} + } + } +
+ } + @if (kind() === 'convert') { +

+ Link an existing corrective task or create an intervention for this qualified request. +

+ @if (workReadable()) { +
+ + + + +
+ Create a corrective intervention + @for (work of openWork(); track work.workItemId) { + {{ + workLabel(work.workItemId) + }} + } +
+
+
+ @if (actionForm().touched()) { + @for (error of actionForm.workItemId().errors(); track error) { + {{ error.message }} + } + } +
+ } @else { +

+ Your permissions do not allow viewing existing interventions. +

+ } + @if (workPending()) { +
+ Loading open work +
+ } + @if (workError(); as error) { +
+

{{ error.message }}

+
+ } + } + @if (error(); as error) { +
+

{{ error.message }}

+
+ } +
+ + +
+
diff --git a/src/app/features/organization/features/service-requests/ui/forms/service-request-action-form/service-request-action-form.component.ts b/src/app/features/organization/features/service-requests/ui/forms/service-request-action-form/service-request-action-form.component.ts new file mode 100644 index 000000000..98ce88bb0 --- /dev/null +++ b/src/app/features/organization/features/service-requests/ui/forms/service-request-action-form/service-request-action-form.component.ts @@ -0,0 +1,549 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + input, + output, + signal, + untracked, +} from '@angular/core'; +import { disabled, form, FormField, maxLength, validate } from '@angular/forms/signals'; +import type { StoreError } from '@core/request-state'; +import type { EquipmentOpenWorkOutput } from '@features/organization/features/equipments/models'; +import type { + ServiceRequestOutput, + QualifyServiceRequestInput, + DecisionServiceRequestInput, +} from '@features/organization/features/service-requests/models'; +import { ServiceRequestEquipmentPicker } from '@features/organization/features/service-requests/ui/components'; +import { HlmAlertImports } from '@shared/ui/alert'; +import { HlmButton } from '@shared/ui/button'; +import { HlmComboboxImports } from '@shared/ui/combobox'; +import { HlmFieldImports } from '@shared/ui/field'; +import { HlmSpinner } from '@shared/ui/spinner'; +import { HlmTextarea } from '@shared/ui/textarea'; +/** + * Type ServiceRequestActionKind + * + * @description + * Explicit qualification, decision and corrective-work conversion actions. + * + * @since unreleased + * + * @type {ServiceRequestActionKind} + */ +export type ServiceRequestActionKind = 'qualify' | 'reject' | 'cancel' | 'convert'; +/** + * Type ServiceRequestActionIntent + * + * @description + * Validated form intent; the owning page supplies transport revision and conversion identity. + * + * @since unreleased + * + * @type {ServiceRequestActionIntent} + */ +export type ServiceRequestActionIntent = + | { readonly kind: 'qualify'; readonly input: QualifyServiceRequestInput } + | { readonly kind: 'reject' | 'cancel'; readonly input: DecisionServiceRequestInput } + | { readonly kind: 'convert'; readonly existingWork: EquipmentOpenWorkOutput | null }; +/** + * Interface ServiceRequestActionDraft + * @interface + * + * @description + * Entered qualification, decision and existing-work selection retained after rejection. + * + * @since unreleased + */ +interface ServiceRequestActionDraft { + /** + * Property note + * + * @description + * Entered qualification explanation, normalized only at submission. + * + * @access public + * @since unreleased + * + * @type {string} + */ + note: string; + /** + * Property reason + * + * @description + * Entered decision reason required for cancellation or rejection. + * + * @access public + * @since unreleased + * + * @type {string} + */ + reason: string; + /** + * Property equipmentId + * + * @description + * Published equipment target or its explicit search scope. + * + * @access public + * @since unreleased + * + * @type {string} + */ + equipmentId: string; + /** + * Property workItemId + * + * @description + * Stable existing corrective task identity selected from authorized work. + * + * @access public + * @since unreleased + * + * @type {string} + */ + workItemId: string; +} +/** + * Class ServiceRequestActionForm + * @class ServiceRequestActionForm + * + * @description + * Explicit qualification, motivated decisions and real corrective-work reuse choices. + * + * @since unreleased + */ +@Component({ + selector: 'app-service-request-action-form', + templateUrl: './service-request-action-form.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + FormField, + HlmButton, + HlmSpinner, + HlmTextarea, + ServiceRequestEquipmentPicker, + ...HlmAlertImports, + ...HlmFieldImports, + ...HlmComboboxImports, + ], +}) +export class ServiceRequestActionForm { + //#region Properties + /** + * Property organizationId + * @readonly + * + * @description + * Organization authority shared by the owning route and server transport. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly organizationId = input.required(); + /** + * Property request + * @readonly + * + * @description + * Immutable request revision supplied by the owning page. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly request = input.required(); + /** + * Property kind + * @readonly + * + * @description + * Explicit workflow action currently being prepared. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly kind = input.required(); + /** + * Property pending + * @readonly + * + * @description + * Disables editing while the owning mutation is accepted. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly pending = input(false); + /** + * Property locked + * @readonly + * + * @description + * Preserves an uncertain conversion payload until the owning page retries it. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly locked = input(false); + /** + * Property error + * @readonly + * + * @description + * Normalized command error displayed without resetting the draft. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly error = input(null); + /** + * Property openWork + * @readonly + * + * @description + * Authorized current repair tasks supplied by the owning page. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly openWork = input([]); + /** + * Property workPending + * @readonly + * + * @description + * A pending open-work read prevents uncertain conversion choices. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly workPending = input(false); + /** + * Property workError + * @readonly + * + * @description + * An unresolved open-work failure remains visible and blocks conversion. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly workError = input(null); + /** + * Property workReadable + * @readonly + * + * @description + * Actual permission to view existing work, distinct from an empty work list. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly workReadable = input(true); + /** + * Property submitted + * @readonly + * + * @description + * Validated intent emitted to the owning page; the form never writes directly. + * + * @access public + * @since unreleased + * + * @type {OutputEmitterRef} + */ + public readonly submitted = output(); + /** + * Property cancelled + * @readonly + * + * @description + * Dismissal intent routed through the owning overlay policy. + * + * @access public + * @since unreleased + * + * @type {OutputEmitterRef} + */ + public readonly cancelled = output(); + /** + * Property dirtyChanged + * @readonly + * + * @description + * Real Signal Forms dirtiness exposed to the owning overlay. + * + * @access public + * @since unreleased + * + * @type {OutputEmitterRef} + */ + public readonly dirtyChanged = output(); + /** + * Property draft + * @readonly + * + * @description + * Editable Signal Forms model retained after server rejection. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal} + */ + protected readonly draft = signal({ + note: '', + reason: '', + equipmentId: '', + workItemId: '', + }); + /** + * Property actionForm + * @readonly + * + * @description + * Qualification, decision and conversion-choice validation. + * + * @access protected + * @since unreleased + * + * @type {FieldTree} + */ + protected readonly actionForm = form(this.draft, (path) => { + disabled(path, () => this.pending() || this.locked()); + maxLength(path.note, 10000, { + message: $localize`:@@serviceRequest.form.max10000:Use at most 10000 characters.`, + }); + maxLength(path.reason, 2000, { + message: $localize`:@@serviceRequest.action.max2000:Use at most 2000 characters.`, + }); + validate(path.reason, ({ value }) => + (this.kind() !== 'reject' && this.kind() !== 'cancel') || value().trim() + ? null + : { + kind: 'required', + message: $localize`:@@serviceRequest.action.reasonRequired:Explain this decision.`, + }, + ); + validate(path.equipmentId, ({ value }) => + this.kind() !== 'qualify' || this.request().equipmentId || value() + ? null + : { + kind: 'required', + message: $localize`:@@serviceRequest.action.equipmentRequired:Identify the equipment requiring maintenance.`, + }, + ); + validate(path.workItemId, ({ value }) => + !value() || this.openWork().some((entry) => entry.workItemId === value()) + ? null + : { + kind: 'invalid', + message: $localize`:@@serviceRequest.action.workRequired:Choose current corrective work.`, + }, + ); + }); + /** + * Property workLabel + * @readonly + * + * @description + * Human-readable name for a real existing corrective task. + * + * @access protected + * @since unreleased + * + * @type {(value: unknown) => string} + */ + protected readonly workLabel = (value: unknown): string => { + if (typeof value !== 'string' || !value) + return $localize`:@@serviceRequest.action.newWork:Create a corrective intervention`; + const work = this.openWork().find((entry) => entry.workItemId === value); + return ( + work?.name || + (work?.number + ? '#' + work.number + : $localize`:@@serviceRequest.action.openWork:Open corrective work`) + ); + }; + /** + * Property remoteFilter + * @readonly + * + * @description + * Server-owned candidate search keeps authorized page entries visible. + * + * @access protected + * @since unreleased + * + * @type {() => boolean} + */ + protected readonly remoteFilter = (): boolean => true; + /** + * Property submitLabel + * @readonly + * + * @description + * Truthful action label derived from the prepared request or work selection. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly submitLabel = computed(() => { + switch (this.kind()) { + case 'qualify': + return $localize`:@@serviceRequest.action.qualify:Confirm qualification`; + case 'reject': + return $localize`:@@serviceRequest.action.reject:Reject request`; + case 'cancel': + return $localize`:@@serviceRequest.action.cancel:Cancel request`; + case 'convert': + return this.draft().workItemId + ? $localize`:@@serviceRequest.action.linkWork:Link this corrective work` + : $localize`:@@serviceRequest.action.createWork:Create corrective intervention`; + } + }); + /** + * Property seededIdentity + * + * @description + * Record/action identity already seeded; a revision refresh must retain the draft. + * + * @access private + * @since unreleased + * + * @type {string} + */ + private seededIdentity = ''; + //#endregion + + //#region Constructor + /** + * Constructor + * @constructor + * + * @description + * Seeds one explicit action and reports real form dirtiness without rebasing a rejected draft. + * + * @access public + * @since unreleased + */ + public constructor() { + effect(() => { + const request = this.request(), + kind = this.kind(), + organizationId = this.organizationId(); + const identity = organizationId + '/' + request.id + '/' + kind; + if (identity === this.seededIdentity) return; + this.seededIdentity = identity; + untracked(() => + this.actionForm().reset({ + note: '', + reason: '', + equipmentId: request.equipmentId ?? '', + workItemId: '', + }), + ); + }); + effect(() => { + const dirty = this.actionForm().dirty(); + untracked(() => this.dirtyChanged.emit(dirty)); + }); + } + //#endregion + + //#region Methods + /** + * Method pickWork + * + * @description + * Selects a real current repair task or an explicit new intervention choice. + * + * @access protected + * @since unreleased + * + * @param {unknown} value - Native choice value validated against the current options. + * + * @returns {void} Result owned by the request workflow. + */ + protected pickWork(value: unknown): void { + if (this.pending() || this.locked() || typeof value !== 'string') return; + if (!value || this.openWork().some((entry) => entry.workItemId === value)) { + this.actionForm.workItemId().value.set(value); + this.actionForm.workItemId().markAsDirty(); + } + } + /** + * Method submit + * + * @description + * Emits normalized validated intent only after exposing errors and checking pending work. + * + * @access protected + * @since unreleased + * + * @param {Event} event - Native submit or input event. + * + * @returns {void} Result owned by the request workflow. + */ + protected submit(event: Event): void { + event.preventDefault(); + this.actionForm().markAsTouched(); + if ( + this.pending() || + this.locked() || + this.actionForm().invalid() || + (this.kind() === 'convert' && (this.workPending() || this.workError())) + ) + return; + const draft = this.draft(); + switch (this.kind()) { + case 'qualify': + this.submitted.emit({ + kind: 'qualify', + input: { + note: draft.note.trim() || null, + ...(!this.request().equipmentId ? { equipmentId: draft.equipmentId } : {}), + }, + }); + return; + case 'reject': + this.submitted.emit({ kind: 'reject', input: { reason: draft.reason.trim() } }); + return; + case 'cancel': + this.submitted.emit({ kind: 'cancel', input: { reason: draft.reason.trim() } }); + return; + case 'convert': + this.submitted.emit({ + kind: 'convert', + existingWork: + this.openWork().find((entry) => entry.workItemId === draft.workItemId) ?? null, + }); + } + } + //#endregion +} diff --git a/src/app/features/organization/features/service-requests/ui/forms/service-request-action-form/testing/service-request-action-form.component.spec.ts b/src/app/features/organization/features/service-requests/ui/forms/service-request-action-form/testing/service-request-action-form.component.spec.ts new file mode 100644 index 000000000..2e66a5c19 --- /dev/null +++ b/src/app/features/organization/features/service-requests/ui/forms/service-request-action-form/testing/service-request-action-form.component.spec.ts @@ -0,0 +1,126 @@ +import { HttpErrorResponse } from '@angular/common/http'; +import { PLATFORM_ID } from '@angular/core'; +import { TestBed, type ComponentFixture } from '@angular/core/testing'; +import { toStoreError } from '@core/request-state'; +import { OrganizationPermissionService } from '@features/organization/access'; +import { + EquipmentService, + EquipmentTypeService, +} from '@features/organization/features/equipments/data-access'; +import type { EquipmentOpenWorkOutput } from '@features/organization/features/equipments/models'; +import { FacilityService } from '@features/organization/features/facilities/data-access'; +import { serviceRequestFixture } from '@features/organization/features/service-requests/models/service-request/testing/service-request.fixture'; +import { + ServiceRequestActionForm, + type ServiceRequestActionIntent, +} from '../service-request-action-form.component'; +describe('ServiceRequestActionForm', () => { + let fixture: ComponentFixture; + beforeAll(() => { + globalThis.ResizeObserver ??= class { + observe(): void {} + unobserve(): void {} + disconnect(): void {} + } as unknown as typeof ResizeObserver; + }); + beforeEach(async () => { + TestBed.configureTestingModule({ + imports: [ServiceRequestActionForm], + providers: [ + { provide: PLATFORM_ID, useValue: 'server' }, + { provide: EquipmentService, useValue: {} }, + { provide: EquipmentTypeService, useValue: {} }, + { provide: FacilityService, useValue: {} }, + { provide: OrganizationPermissionService, useValue: { hasPermission: () => true } }, + ], + }); + fixture = TestBed.createComponent(ServiceRequestActionForm); + fixture.componentRef.setInput('organizationId', 'org'); + fixture.componentRef.setInput('request', serviceRequestFixture({ equipmentId: null })); + fixture.componentRef.setInput('kind', 'qualify'); + await fixture.whenStable(); + }); + const submit = async (): Promise => { + (fixture.nativeElement.querySelector('form') as HTMLFormElement).dispatchEvent( + new Event('submit', { cancelable: true }), + ); + await fixture.whenStable(); + }; + it('requires an explicit equipment for a site-only qualification and emits its trimmed note', async () => { + const emitted: ServiceRequestActionIntent[] = []; + fixture.componentInstance.submitted.subscribe((input) => emitted.push(input)); + await submit(); + expect(emitted).toEqual([]); + fixture.componentInstance['actionForm'].equipmentId().value.set('equipment'); + fixture.componentInstance['actionForm'].note().value.set(' Repair the gauge '); + await submit(); + expect(emitted).toEqual([ + { kind: 'qualify', input: { equipmentId: 'equipment', note: 'Repair the gauge' } }, + ]); + }); + it('never resends a replacement equipment identity for an already targeted request', async () => { + fixture.componentRef.setInput('request', serviceRequestFixture({ id: 'targeted' })); + await fixture.whenStable(); + const emitted: ServiceRequestActionIntent[] = []; + fixture.componentInstance.submitted.subscribe((input) => emitted.push(input)); + await submit(); + expect(emitted).toEqual([{ kind: 'qualify', input: { note: null } }]); + }); + it('requires a motivated decision and retains its draft after a stale server revision', async () => { + fixture.componentRef.setInput('kind', 'reject'); + await fixture.whenStable(); + const emitted: ServiceRequestActionIntent[] = []; + fixture.componentInstance.submitted.subscribe((input) => emitted.push(input)); + fixture.componentInstance['actionForm'].reason().value.set(' '); + await submit(); + expect(emitted).toEqual([]); + fixture.componentInstance['actionForm'].reason().value.set(' Already resolved '); + fixture.componentInstance['actionForm'].reason().markAsDirty(); + fixture.componentRef.setInput('error', toStoreError(new HttpErrorResponse({ status: 412 }))); + fixture.componentRef.setInput('request', serviceRequestFixture({ revision: 9 })); + await fixture.whenStable(); + await submit(); + expect(emitted).toEqual([{ kind: 'reject', input: { reason: 'Already resolved' } }]); + }); + it('selects a real existing repair task and blocks conversion while its open-work read is unresolved', async () => { + const work: EquipmentOpenWorkOutput = { + '@id': 'work', + '@type': 'OpenWork', + interventionId: 'intervention', + workItemId: 'task', + name: 'Gauge repair', + status: 'planned', + action: 'repair', + workItemStatus: 'planned', + }; + fixture.componentRef.setInput('kind', 'convert'); + fixture.componentRef.setInput('openWork', [work]); + await fixture.whenStable(); + const emitted: ServiceRequestActionIntent[] = []; + fixture.componentInstance.submitted.subscribe((input) => emitted.push(input)); + fixture.componentInstance['pickWork']('task'); + fixture.componentRef.setInput('workPending', true); + await fixture.whenStable(); + await submit(); + expect(emitted).toEqual([]); + fixture.componentRef.setInput('workPending', false); + await fixture.whenStable(); + await submit(); + expect(emitted).toEqual([{ kind: 'convert', existingWork: work }]); + }); + it('locks an uncertain conversion without announcing an active request or allowing a new submission', async () => { + fixture.componentRef.setInput('kind', 'convert'); + fixture.componentRef.setInput('locked', true); + await fixture.whenStable(); + const emitted: ServiceRequestActionIntent[] = []; + fixture.componentInstance.submitted.subscribe((input) => emitted.push(input)); + const submitButton = fixture.nativeElement.querySelector( + 'button[type="submit"]', + ) as HTMLButtonElement; + expect(submitButton.disabled).toBe(true); + expect(submitButton.textContent?.trim()).toBe('Create corrective intervention'); + expect(fixture.nativeElement.querySelector('hlm-spinner')).toBeNull(); + await submit(); + expect(emitted).toEqual([]); + }); +}); diff --git a/src/app/features/organization/features/service-requests/ui/forms/service-request-form/service-request-form.component.html b/src/app/features/organization/features/service-requests/ui/forms/service-request-form/service-request-form.component.html new file mode 100644 index 000000000..6f7801c1a --- /dev/null +++ b/src/app/features/organization/features/service-requests/ui/forms/service-request-form/service-request-form.component.html @@ -0,0 +1,143 @@ +
+ @if (!request()) { +
+ Maintenance target +
+
+ + +

+ A site-only request can be qualified once its equipment is identified. +

+
+
+ + + @if (requestForm().touched()) { + @for (error of requestForm.equipmentId().errors(); track error) { + {{ error.message }} + } + } +
+
+
+ } +
+ + + @if (requestForm.title().touched()) { + @for (error of requestForm.title().errors(); track error) { + {{ error.message }} + } + } +
+
+ + + @if (requestForm.description().touched()) { + @for (error of requestForm.description().errors(); track error) { + {{ error.message }} + } + } +
+
+ Priority + + + + + + +
+ @if (error(); as error) { +
+

{{ error.message }}

+
+ } +
+ + +
+
diff --git a/src/app/features/organization/features/service-requests/ui/forms/service-request-form/service-request-form.component.ts b/src/app/features/organization/features/service-requests/ui/forms/service-request-form/service-request-form.component.ts new file mode 100644 index 000000000..8873dd411 --- /dev/null +++ b/src/app/features/organization/features/service-requests/ui/forms/service-request-form/service-request-form.component.ts @@ -0,0 +1,503 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + input, + output, + signal, + untracked, +} from '@angular/core'; +import { disabled, form, FormField, maxLength, required, validate } from '@angular/forms/signals'; +import type { StoreError } from '@core/request-state'; +import type { + ServiceRequestOutput, + ServiceRequestPriority, + CreateServiceRequestInput, +} from '@features/organization/features/service-requests/models'; +import { + ServiceRequestEquipmentPicker, + ServiceRequestSitePicker, +} from '@features/organization/features/service-requests/ui/components'; +import { HlmAlertImports } from '@shared/ui/alert'; +import { HlmButton } from '@shared/ui/button'; +import { HlmFieldImports } from '@shared/ui/field'; +import { HlmInput } from '@shared/ui/input'; +import { HlmSpinner } from '@shared/ui/spinner'; +import { HlmTextarea } from '@shared/ui/textarea'; +import { HlmToggleGroupImports } from '@shared/ui/toggle-group'; +/** + * Interface ServiceRequestDraft + * @interface + * + * @description + * Entered description and target fields owned by Signal Forms. + * + * @since unreleased + */ +interface ServiceRequestDraft { + /** + * Property title + * + * @description + * Declared request title or localized overlay title. + * + * @access public + * @since unreleased + * + * @type {string} + */ + title: string; + /** + * Property description + * + * @description + * Entered maintenance need retained while a command is rejected. + * + * @access public + * @since unreleased + * + * @type {string} + */ + description: string; + /** + * Property priority + * + * @description + * Declared scheduling priority using the server vocabulary. + * + * @access public + * @since unreleased + * + * @type {ServiceRequestPriority} + */ + priority: ServiceRequestPriority; + /** + * Property equipmentId + * + * @description + * Published equipment target or its explicit search scope. + * + * @access public + * @since unreleased + * + * @type {string} + */ + equipmentId: string; + /** + * Property siteId + * + * @description + * Root-site target or scope; qualification choices stay inside this site. + * + * @access public + * @since unreleased + * + * @type {string} + */ + siteId: string; +} +/** + * Class ServiceRequestForm + * @class ServiceRequestForm + * + * @description + * Validated request description and optional site-only target; a revision refresh never overwrites + * an entered draft. + * + * @since unreleased + */ +@Component({ + selector: 'app-service-request-form', + templateUrl: './service-request-form.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + FormField, + HlmButton, + HlmInput, + HlmTextarea, + HlmSpinner, + ServiceRequestEquipmentPicker, + ServiceRequestSitePicker, + ...HlmFieldImports, + ...HlmAlertImports, + ...HlmToggleGroupImports, + ], +}) +export class ServiceRequestForm { + //#region Properties + /** + * Property organizationId + * @readonly + * + * @description + * Organization authority shared by the owning route and server transport. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly organizationId = input.required(); + /** + * Property request + * @readonly + * + * @description + * Immutable request revision supplied by the owning page. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly request = input(null); + /** + * Property initialEquipmentId + * @readonly + * + * @description + * Optional equipment prefill supplied by a dossier entry link. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly initialEquipmentId = input(''); + /** + * Property initialSiteId + * @readonly + * + * @description + * Optional root-site prefill supplied by a dossier entry link. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly initialSiteId = input(''); + /** + * Property originInspectionId + * @readonly + * + * @description + * Optional source inspection preserved in the creation input. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly originInspectionId = input(''); + /** + * Property originNonConformityId + * @readonly + * + * @description + * Optional source anomaly preserved in the creation input. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly originNonConformityId = input(''); + /** + * Property pending + * @readonly + * + * @description + * Disables editing while the owning mutation is accepted. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly pending = input(false); + /** + * Property error + * @readonly + * + * @description + * Normalized command error displayed without resetting the draft. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly error = input(null); + /** + * Property submitted + * @readonly + * + * @description + * Validated intent emitted to the owning page; the form never writes directly. + * + * @access public + * @since unreleased + * + * @type {OutputEmitterRef} + */ + public readonly submitted = output(); + /** + * Property cancelled + * @readonly + * + * @description + * Dismissal intent routed through the owning overlay policy. + * + * @access public + * @since unreleased + * + * @type {OutputEmitterRef} + */ + public readonly cancelled = output(); + /** + * Property dirtyChanged + * @readonly + * + * @description + * Real Signal Forms dirtiness exposed to the owning overlay. + * + * @access public + * @since unreleased + * + * @type {OutputEmitterRef} + */ + public readonly dirtyChanged = output(); + /** + * Property draft + * @readonly + * + * @description + * Editable Signal Forms model retained after server rejection. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal} + */ + protected readonly draft = signal({ + title: '', + description: '', + priority: 'normal', + equipmentId: '', + siteId: '', + }); + /** + * Property requestForm + * @readonly + * + * @description + * Description and cross-field target validation with server length bounds. + * + * @access protected + * @since unreleased + * + * @type {FieldTree} + */ + protected readonly requestForm = form(this.draft, (path) => { + disabled(path, () => this.pending()); + required(path.title, { + message: $localize`:@@serviceRequest.form.titleRequired:Describe the request with a title.`, + }); + required(path.description, { + message: $localize`:@@serviceRequest.form.descriptionRequired:Describe the maintenance need.`, + }); + maxLength(path.title, 160, { + message: $localize`:@@serviceRequest.form.max160:Use at most 160 characters.`, + }); + maxLength(path.description, 10000, { + message: $localize`:@@serviceRequest.form.max10000:Use at most 10000 characters.`, + }); + validate(path.title, ({ value }) => + value().trim() + ? null + : { + kind: 'required', + message: $localize`:@@serviceRequest.form.titleRequired:Describe the request with a title.`, + }, + ); + validate(path.description, ({ value }) => + value().trim() + ? null + : { + kind: 'required', + message: $localize`:@@serviceRequest.form.descriptionRequired:Describe the maintenance need.`, + }, + ); + validate(path.equipmentId, ({ value, valueOf }) => + this.request() || value().trim() || valueOf(path.siteId).trim() + ? null + : { + kind: 'required', + message: $localize`:@@serviceRequest.form.targetRequired:Choose equipment or a site.`, + }, + ); + }); + /** + * Property submitLabel + * @readonly + * + * @description + * Truthful action label derived from the prepared request or work selection. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly submitLabel = computed(() => + this.request() + ? $localize`:@@serviceRequest.form.save:Save request` + : $localize`:@@serviceRequest.form.create:Submit maintenance request`, + ); + /** + * Property seededIdentity + * + * @description + * Record/action identity already seeded; a revision refresh must retain the draft. + * + * @access private + * @since unreleased + * + * @type {string | undefined} + */ + private seededIdentity: string | undefined; + /** + * Property previousSiteId + * + * @description + * Previously seeded or selected site distinguishes hydration from a user target change. + * + * @access private + * @since unreleased + * + * @type {string} + */ + private previousSiteId = ''; + //#endregion + + //#region Constructor + /** + * Constructor + * @constructor + * + * @description + * Seeds a new record identity and reports real form dirtiness; revision refreshes retain entered + * fields. + * + * @access public + * @since unreleased + */ + public constructor() { + effect(() => { + const request = this.request(), + org = this.organizationId(), + equipmentId = this.initialEquipmentId(), + siteId = this.initialSiteId(); + const identity = org + '/' + (request?.id ?? 'new'); + if (identity === this.seededIdentity) return; + this.seededIdentity = identity; + this.previousSiteId = request?.siteId ?? siteId; + untracked(() => + this.requestForm().reset( + request + ? { + title: request.title, + description: request.description, + priority: request.priority, + equipmentId: request.equipmentId ?? '', + siteId: request.siteId ?? '', + } + : { title: '', description: '', priority: 'normal', equipmentId, siteId }, + ), + ); + }); + effect(() => { + const dirty = this.requestForm().dirty(); + untracked(() => this.dirtyChanged.emit(dirty)); + }); + } + //#endregion + + //#region Methods + /** + * Method priorityChanged + * + * @description + * Accepts only canonical priority choices without clearing other entered fields. + * + * @access protected + * @since unreleased + * + * @param {unknown} value - Native choice value validated against the current options. + * + * @returns {void} Result owned by the request workflow. + */ + protected priorityChanged(value: unknown): void { + if (this.pending()) return; + if (value === 'low' || value === 'normal' || value === 'high' || value === 'urgent') { + this.requestForm.priority().value.set(value); + this.requestForm.priority().markAsDirty(); + } + } + /** + * Method siteChanged + * + * @description + * Clears an equipment choice after a real site change while preserving initial hydration. + * + * @access protected + * @since unreleased + * + * @param {string} siteId - Newly emitted root-site identity. + * + * @returns {void} Result owned by the request workflow. + */ + protected siteChanged(siteId: string): void { + if (this.previousSiteId === siteId) return; + this.previousSiteId = siteId; + this.requestForm.equipmentId().value.set(''); + } + /** + * Method submit + * + * @description + * Emits normalized validated intent only after exposing errors and checking pending work. + * + * @access protected + * @since unreleased + * + * @param {Event} event - Native submit or input event. + * + * @returns {void} Result owned by the request workflow. + */ + protected submit(event: Event): void { + event.preventDefault(); + this.requestForm().markAsTouched(); + if (this.pending() || this.requestForm().invalid()) return; + const draft = this.draft(); + const description = { + title: draft.title.trim(), + description: draft.description.trim(), + priority: draft.priority, + }; + this.submitted.emit( + this.request() + ? description + : { + ...description, + equipmentId: draft.equipmentId || null, + siteId: draft.siteId || null, + originInspectionId: this.originInspectionId() || null, + originNonConformityId: this.originNonConformityId() || null, + }, + ); + } + //#endregion +} diff --git a/src/app/features/organization/features/service-requests/ui/forms/service-request-form/testing/service-request-form.component.spec.ts b/src/app/features/organization/features/service-requests/ui/forms/service-request-form/testing/service-request-form.component.spec.ts new file mode 100644 index 000000000..fe103f563 --- /dev/null +++ b/src/app/features/organization/features/service-requests/ui/forms/service-request-form/testing/service-request-form.component.spec.ts @@ -0,0 +1,250 @@ +import { HttpErrorResponse } from '@angular/common/http'; +import { PLATFORM_ID } from '@angular/core'; +import { TestBed, type ComponentFixture } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { of } from 'rxjs'; +import { toStoreError } from '@core/request-state'; +import { OrganizationPermissionService } from '@features/organization/access'; +import { + EquipmentService, + EquipmentTypeService, +} from '@features/organization/features/equipments/data-access'; +import type { EquipmentOutput } from '@features/organization/features/equipments/models'; +import { FacilityService } from '@features/organization/features/facilities/data-access'; +import type { FacilityOutput } from '@features/organization/features/facilities/models'; +import { FacilityOptionPicker } from '@features/organization/features/facilities/ui/components'; +import type { CreateServiceRequestInput } from '@features/organization/features/service-requests/models'; +import { serviceRequestFixture } from '@features/organization/features/service-requests/models/service-request/testing/service-request.fixture'; +import { + ServiceRequestEquipmentPicker, + ServiceRequestSitePicker, +} from '@features/organization/features/service-requests/ui/components'; +import { ServiceRequestForm } from '../service-request-form.component'; +describe('ServiceRequestForm', () => { + let fixture: ComponentFixture; + let equipment: { + list: ReturnType; + listByFacility: ReturnType; + get: ReturnType; + }; + let facilities: { list: ReturnType; get: ReturnType }; + const site: FacilityOutput = { + '@id': '/api/organizations/org/facilities/site', + '@type': 'Facility', + id: 'site', + organizationId: 'org', + parentFacilityId: null, + hasChildren: false, + type: 'site', + name: 'Hospital', + code: 'HOSPITAL', + status: 'active', + address: null, + metadata: {}, + path: [], + equipmentCount: 1, + recordStatus: 'published', + createdAt: '2026-10-06T10:00:00Z', + updatedAt: '2026-10-06T10:00:00Z', + }; + const secondSite: FacilityOutput = { ...site, id: 'second-site', name: 'Annex' }; + const extinguisher: EquipmentOutput = { + '@id': '/api/organizations/org/equipment/equipment', + '@type': 'Equipment', + id: 'equipment', + organizationId: 'org', + facilityId: site.id, + facilityName: site.name, + type: 'fire_extinguisher', + subType: null, + name: 'Extinguisher A', + brand: null, + model: null, + serialNumber: null, + locationLabel: null, + status: 'operational', + recordStatus: 'published', + installedAt: null, + commissionedAt: null, + tags: [], + maintenanceDueStatus: 'unscheduled', + createdAt: '2026-10-06T10:00:00Z', + updatedAt: '2026-10-06T10:00:00Z', + }; + beforeAll(() => { + globalThis.ResizeObserver ??= class { + observe(): void {} + unobserve(): void {} + disconnect(): void {} + } as unknown as typeof ResizeObserver; + }); + beforeEach(async () => { + equipment = { + list: vi.fn().mockReturnValue(of({ member: [extinguisher], totalItems: 1 })), + listByFacility: vi.fn().mockReturnValue(of({ member: [extinguisher], totalItems: 1 })), + get: vi.fn().mockReturnValue(of(extinguisher)), + }; + facilities = { + list: vi.fn().mockReturnValue(of({ member: [site, secondSite], totalItems: 2 })), + get: vi + .fn() + .mockImplementation((_organizationId: string, siteId: string) => + of(siteId === secondSite.id ? secondSite : site), + ), + }; + TestBed.configureTestingModule({ + imports: [ServiceRequestForm], + providers: [ + { provide: PLATFORM_ID, useValue: 'browser' }, + { provide: EquipmentService, useValue: equipment }, + { provide: EquipmentTypeService, useValue: { listAll: vi.fn().mockReturnValue(of([])) } }, + { provide: FacilityService, useValue: facilities }, + { provide: OrganizationPermissionService, useValue: { hasPermission: () => true } }, + ], + }); + fixture = TestBed.createComponent(ServiceRequestForm); + fixture.componentRef.setInput('organizationId', 'org'); + await fixture.whenStable(); + }); + const fill = async (selector: string, value: string): Promise => { + const input = fixture.nativeElement.querySelector(selector) as HTMLInputElement; + input.value = value; + input.dispatchEvent(new Event('input')); + await fixture.whenStable(); + }; + const submit = async (): Promise => { + (fixture.nativeElement.querySelector('form') as HTMLFormElement).dispatchEvent( + new Event('submit', { cancelable: true }), + ); + await fixture.whenStable(); + }; + it('requires a real target and nonblank description before emitting a normalized site-only request', async () => { + const emitted: CreateServiceRequestInput[] = []; + fixture.componentInstance.submitted.subscribe((input) => emitted.push(input)); + await fill('#service-request-title', ' Gauge repair '); + await fill('#service-request-description', ' '); + await submit(); + expect(emitted).toEqual([]); + await fill('#service-request-description', ' Damaged gauge '); + await submit(); + expect(emitted).toEqual([]); + const picker = fixture.debugElement.query(By.directive(FacilityOptionPicker)) + .componentInstance as FacilityOptionPicker; + picker.value.set(site.id); + await fixture.whenStable(); + expect(fixture.debugElement.query(By.directive(ServiceRequestSitePicker))).not.toBeNull(); + expect(fixture.debugElement.query(By.directive(ServiceRequestEquipmentPicker))).not.toBeNull(); + expect( + (fixture.nativeElement.querySelector('#service-request-site') as HTMLInputElement).value, + ).toBe(site.name); + await submit(); + expect(emitted).toEqual([ + { + title: 'Gauge repair', + description: 'Damaged gauge', + priority: 'normal', + equipmentId: null, + siteId: 'site', + originInspectionId: null, + originNonConformityId: null, + }, + ]); + }); + it('binds both real custom target controls and clears the equipment when the selected site changes', async () => { + const emitted: CreateServiceRequestInput[] = []; + fixture.componentInstance.submitted.subscribe((input) => emitted.push(input)); + const sitePicker = fixture.debugElement.query(By.directive(FacilityOptionPicker)) + .componentInstance as FacilityOptionPicker; + const equipmentPicker = fixture.debugElement.query(By.directive(ServiceRequestEquipmentPicker)) + .componentInstance as ServiceRequestEquipmentPicker; + expect(facilities.list).toHaveBeenCalledWith('org', { + page: 1, + itemsPerPage: 30, + search: '', + params: { rootsOnly: true }, + }); + sitePicker.value.set(site.id); + await fixture.whenStable(); + equipmentPicker.value.set(extinguisher.id); + await fixture.whenStable(); + await fill('#service-request-title', 'Repair'); + await fill('#service-request-description', 'Damaged gauge'); + await submit(); + expect(emitted).toEqual([ + { + title: 'Repair', + description: 'Damaged gauge', + priority: 'normal', + equipmentId: extinguisher.id, + siteId: site.id, + originInspectionId: null, + originNonConformityId: null, + }, + ]); + expect(equipment.listByFacility).toHaveBeenLastCalledWith('org', site.id, { + page: 1, + itemsPerPage: 30, + search: '', + params: { includeDescendants: true }, + }); + sitePicker.value.set(secondSite.id); + await fixture.whenStable(); + expect(equipmentPicker.value()).toBe(''); + await submit(); + expect(emitted[1]).toEqual({ + ...emitted[0], + equipmentId: null, + siteId: secondSite.id, + }); + expect(equipment.listByFacility).toHaveBeenLastCalledWith('org', secondSite.id, { + page: 1, + itemsPerPage: 30, + search: '', + params: { includeDescendants: true }, + }); + }); + it('preserves an edited draft after server rejection and an explicitly reviewed revision refresh', async () => { + fixture.componentRef.setInput('request', serviceRequestFixture()); + await fixture.whenStable(); + await fill('#service-request-title', 'My entered draft'); + fixture.componentRef.setInput('error', toStoreError(new HttpErrorResponse({ status: 412 }))); + fixture.componentRef.setInput( + 'request', + serviceRequestFixture({ revision: 9, title: 'Server revision' }), + ); + await fixture.whenStable(); + expect( + (fixture.nativeElement.querySelector('#service-request-title') as HTMLInputElement).value, + ).toBe('My entered draft'); + expect(fixture.componentInstance['requestForm']().dirty()).toBe(true); + expect(fixture.nativeElement.querySelector('[role="alert"]')).toBeTruthy(); + }); + it('does not erase the seeded equipment when the matching initial site is emitted by a custom control', async () => { + fixture.componentRef.setInput('organizationId', 'other'); + fixture.componentRef.setInput('initialSiteId', 'site'); + fixture.componentRef.setInput('initialEquipmentId', 'equipment'); + await fixture.whenStable(); + fixture.componentInstance['siteChanged']('site'); + expect(fixture.componentInstance['requestForm'].equipmentId().value()).toBe('equipment'); + fixture.componentInstance['siteChanged']('different'); + expect(fixture.componentInstance['requestForm'].equipmentId().value()).toBe(''); + }); + it('blocks submissions while pending and validates both server length bounds', async () => { + const emitted: CreateServiceRequestInput[] = []; + fixture.componentInstance.submitted.subscribe((input) => emitted.push(input)); + fixture.componentInstance['requestForm'].equipmentId().value.set('equipment'); + await fill('#service-request-title', 'a'.repeat(161)); + await fill('#service-request-description', 'Valid description'); + await submit(); + expect(emitted).toEqual([]); + await fill('#service-request-title', 'Repair'); + await fill('#service-request-description', 'a'.repeat(10001)); + await submit(); + expect(emitted).toEqual([]); + await fill('#service-request-description', 'Valid description'); + fixture.componentRef.setInput('pending', true); + await fixture.whenStable(); + await submit(); + expect(emitted).toEqual([]); + }); +}); diff --git a/src/app/features/organization/features/service-requests/ui/pages/service-request-detail-page/service-request-detail-page.component.html b/src/app/features/organization/features/service-requests/ui/pages/service-request-detail-page/service-request-detail-page.component.html new file mode 100644 index 000000000..f2078a111 --- /dev/null +++ b/src/app/features/organization/features/service-requests/ui/pages/service-request-detail-page/service-request-detail-page.component.html @@ -0,0 +1,302 @@ +
+ Maintenance requests + @if (store.commandCallState().error) { +
+

+ Saved maintenance operations could not be restored. Retry before starting another action. +

+ +
+ } + @if (retainedConversion() && store.conversionUncertain() && !editorVisible()) { +
+

+ The work link has not been confirmed. Retry this conversion before choosing another action. +

+ +
+ } + @if ( + (store.readCallState().status === 'pending' || store.readCallState().status === 'idle') && + !current() + ) { +
+ +
+ } @else if (current(); as request) { + @if (store.readCallState().error; as error) { +
+

{{ error.message }}

+ +
+ } +
+
+ {{ statusLabel(request.status) }}{{ priorityLabel(request.priority) }} +
+

{{ request.title }}

+

{{ request.description }}

+

+ Requested · + {{ request.requestedAt | appOrgDate: 'datetime' : regionalFormatting() }} +

+
+ @if (allowed('update', request)) { + + } + @if (allowed('qualify', request)) { + + } + @if (allowed('convert', request)) { + + } + @if (allowed('reject', request)) { + + } + @if (allowed('cancel', request)) { + + } +
+
+
+

+ Maintenance target +

+
+ @if (request.targetSnapshot.equipment; as equipment) { +
+
+ Equipment +
+
+ {{ equipment.name ?? equipment.assetCode ?? equipment.id }} +
+ @if (canReadEquipment() && request.equipmentId) { + Open equipment dossier + } +
+ } + @if (request.targetSnapshot.site; as site) { +
+
Site
+
{{ site.name }}
+ @if (canReadSites()) { + Open site dossier + } +
+ } + @if (request.targetSnapshot.customer; as customer) { +
+
Customer
+
{{ customer.name }}
+
+ } +
+ @if (request.originInspectionId && canReadInspections()) { + Open source inspection + } +
+ @if (request.qualificationNote || request.qualifiedAt) { +
+

Qualification

+ @if (request.qualifiedAt) { +

+ {{ request.qualifiedAt | appOrgDate: 'datetime' : regionalFormatting() }} +

+ } + @if (request.qualificationNote) { +

{{ request.qualificationNote }}

+ } +
+ } + @if (request.decisionReason) { +
+

Decision reason

+

{{ request.decisionReason }}

+
+ } + @if (request.interventionId) { +
+

Corrective work

+ @if (request.convertedAt) { +

+ {{ request.convertedAt | appOrgDate: 'datetime' : regionalFormatting() }} +

+ } + @if (canReadInterventions()) { + Open corrective intervention + } +
+ } + } @else if (store.readCallState().error; as error) { +
+
+

+ This request could not be opened +

+

{{ error.message }}

+
+
+ +
+
+ } + @for (owner of [editorOwnerKey()]; track owner) { + + } +
diff --git a/src/app/features/organization/features/service-requests/ui/pages/service-request-detail-page/service-request-detail-page.component.ts b/src/app/features/organization/features/service-requests/ui/pages/service-request-detail-page/service-request-detail-page.component.ts new file mode 100644 index 000000000..e5a5cbaeb --- /dev/null +++ b/src/app/features/organization/features/service-requests/ui/pages/service-request-detail-page/service-request-detail-page.component.ts @@ -0,0 +1,923 @@ +import { isPlatformBrowser } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + PLATFORM_ID, + computed, + effect, + inject, + input, + signal, + untracked, + viewChild, + type Signal, +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { RouterLink } from '@angular/router'; +import { Events } from '@ngrx/signals/events'; +import { ConnectivityService } from '@core/connectivity'; +import { AUTH_SESSION_PORT } from '@features/auth/ports'; +import { OrganizationPermissionService } from '@features/organization/access'; +import type { + ServiceRequestOutput, + CreateServiceRequestInput, +} from '@features/organization/features/service-requests/models'; +import { + ServiceRequestStore, + ServiceRequestTargetStore, + serviceRequestStoreEvents, + type ServiceRequestStoreType, +} from '@features/organization/features/service-requests/state'; +import { + serviceRequestStatusLabel, + serviceRequestPriorityLabel, +} from '@features/organization/features/service-requests/utils'; +import { ORGANIZATION_PERMISSION } from '@features/organization/models'; +import { + ORGANIZATION_MEMBER_ACCESS_PORT, + REGIONAL_FORMATTING_PORT, + type OrganizationMemberAccessPort, +} from '@features/organization/ports'; +import { OrgDatePipe } from '@shared/regional-format'; +import { HlmAlertImports } from '@shared/ui/alert'; +import { HlmBadge } from '@shared/ui/badge'; +import { HlmButton } from '@shared/ui/button'; +import { HlmEmptyImports } from '@shared/ui/empty'; +import { HlmSkeleton } from '@shared/ui/skeleton'; +import { HlmLarge } from '@shared/ui/typography'; +import type { ServiceRequestActionIntent } from '../../forms/service-request-action-form/service-request-action-form.component'; +import { + ServiceRequestEditorSheet, + type ServiceRequestEditorKind, +} from '../../sheets/service-request-editor-sheet/service-request-editor-sheet.component'; +/** + * Class ServiceRequestDetailPage + * @class ServiceRequestDetailPage + * + * @description + * Request dossier separates qualification and work conversion while preserving target identity and + * revision review. + * + * @since unreleased + */ +@Component({ + selector: 'app-service-request-detail-page', + templateUrl: './service-request-detail-page.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ServiceRequestStore, ServiceRequestTargetStore], + imports: [ + RouterLink, + OrgDatePipe, + HlmBadge, + HlmButton, + HlmLarge, + HlmSkeleton, + ServiceRequestEditorSheet, + ...HlmAlertImports, + ...HlmEmptyImports, + ], +}) +export class ServiceRequestDetailPage { + //#region Properties + /** + * Property organizationId + * @readonly + * + * @description + * Organization authority shared by the owning route and server transport. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly organizationId = input.required(); + /** + * Property requestId + * @readonly + * + * @description + * Stable request identity supplied by the detail route. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly requestId = input.required(); + /** + * Property store + * @readonly + * + * @description + * Component-owned request workflow state. + * + * @access protected + * @since unreleased + * + * @type {ServiceRequestStoreType} + */ + protected readonly store = inject(ServiceRequestStore); + /** + * Property sessionRevision + * @readonly + * + * @description + * Session replacement invalidates the route's actor-private editor without reading credentials. + * + * @access private + * @since unreleased + * + * @type {Signal} + */ + private readonly sessionRevision: Signal = inject(AUTH_SESSION_PORT).sessionRevision; + + /** + * Property isAuthenticated + * @readonly + * + * @description + * Established local session whose termination must permit authentication redirects. + * + * @access private + * @since unreleased + * + * @type {Signal} + */ + private readonly isAuthenticated: Signal = inject(AUTH_SESSION_PORT).isAuthenticated; + + /** + * Property memberAccess + * @readonly + * + * @description + * Published member identity distinguishes actors whose organization permissions are identical. + * + * @access private + * @since unreleased + * + * @type {OrganizationMemberAccessPort} + */ + private readonly memberAccess: OrganizationMemberAccessPort = inject( + ORGANIZATION_MEMBER_ACCESS_PORT, + ); + + /** + * Property editorOwnerKey + * @readonly + * + * @description + * Stable dossier, actor and session identity destroys the previous native editor when its owner + * changes. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly editorOwnerKey: Signal = computed(() => + JSON.stringify([ + this.organizationId(), + this.requestId(), + this.sessionRevision(), + this.memberAccess.profile()?.userId ?? null, + ]), + ); + /** + * Property targets + * @readonly + * + * @description + * Component-owned target and open-work read state. + * + * @access protected + * @since unreleased + * + * @type {ServiceRequestTargetStoreType} + */ + protected readonly targets = inject(ServiceRequestTargetStore); + /** + * Property permissions + * @readonly + * + * @description + * API-derived organization permission authority. + * + * @access private + * @since unreleased + * + * @type {OrganizationPermissionService} + */ + private readonly permissions = inject(OrganizationPermissionService); + /** + * Property connectivity + * @readonly + * + * @description + * Online state used without discarding an entered draft. + * + * @access private + * @since unreleased + * + * @type {ConnectivityService} + */ + private readonly connectivity = inject(ConnectivityService); + /** + * Property platformId + * @readonly + * + * @description + * Browser guard for secondary authenticated reads. + * + * @access private + * @since unreleased + * + * @type {object} + */ + private readonly platformId = inject(PLATFORM_ID); + /** + * Property regionalFormatting + * @readonly + * + * @description + * Organization timezone and date pattern for retained history. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly regionalFormatting = inject(REGIONAL_FORMATTING_PORT).regionalFormatting; + /** + * Property current + * @readonly + * + * @description + * Current request data, scoped by organization and request identity. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly current = computed(() => this.store.readCallState().data); + /** + * Property online + * @readonly + * + * @description + * Observed network state for explicit online commands. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly online = computed(() => this.connectivity.isOnline()); + /** + * Property canRead + * @readonly + * + * @description + * Permission gate for the owned resource reads. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly canRead = computed(() => + this.permissions.hasPermission(ORGANIZATION_PERMISSION.SERVICE_REQUESTS_READ), + ); + /** + * Property canManage + * @readonly + * + * @description + * Dedicated qualification, decision and conversion grant. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly canManage = computed(() => + this.permissions.hasPermission(ORGANIZATION_PERMISSION.SERVICE_REQUESTS_MANAGE), + ); + /** + * Property canReadEquipment + * @readonly + * + * @description + * Equipment dossier and target-choice read grant. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly canReadEquipment = computed(() => + this.permissions.hasPermission(ORGANIZATION_PERMISSION.EQUIPMENT_READ), + ); + /** + * Property canReadSites + * @readonly + * + * @description + * Site dossier and root-site choice read grant. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly canReadSites = computed(() => + this.permissions.hasPermission(ORGANIZATION_PERMISSION.FACILITIES_READ), + ); + /** + * Property canReadInterventions + * @readonly + * + * @description + * Intervention dossier and open-work read grant. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly canReadInterventions = computed(() => + this.permissions.hasPermission(ORGANIZATION_PERMISSION.INTERVENTIONS_READ), + ); + /** + * Property canReadInspections + * @readonly + * + * @description + * Source inspection dossier read grant. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly canReadInspections = computed(() => + this.permissions.hasPermission(ORGANIZATION_PERMISSION.INSPECTION_READ), + ); + /** + * Property canPlan + * @readonly + * + * @description + * Intervention planning grant required in addition to request management. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly canPlan = computed(() => + this.permissions.hasPermission(ORGANIZATION_PERMISSION.INTERVENTIONS_PLAN), + ); + /** + * Property canReadWork + * @readonly + * + * @description + * Both equipment and intervention read grants are required for open-work choices. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly canReadWork = computed( + () => this.canReadEquipment() && this.canReadInterventions(), + ); + /** + * Property kind + * @readonly + * + * @description + * Explicit workflow action currently being prepared. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal} + */ + protected readonly kind = signal('update'); + /** + * Property editing + * @readonly + * + * @description + * Immutable displayed revision captured before an action is submitted. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal} + */ + protected readonly editing = signal(null); + /** + * Property editorVisible + * @readonly + * + * @description + * Overlay visibility owned by the page and confirmed mutation feedback. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal} + */ + protected readonly editorVisible = signal(false); + /** + * Property editorSheet + * @readonly + * + * @description + * Active native editor owns the entered draft and its discard confirmation. + * + * @access private + * @since unreleased + * + * @type {Signal} + */ + private readonly editorSheet: Signal = + viewChild(ServiceRequestEditorSheet); + + /** + * Property retainedConversion + * @readonly + * + * @description + * Durable conversion belongs to this dossier and prevents preparing a competing operation. + * + * @access protected + * @since unreleased + * + * @type {Signal>} + */ + protected readonly retainedConversion: Signal< + ReturnType + > = computed(() => { + const command = this.store.conversionCommand(); + return command?.organizationId === this.organizationId() && + command.request.id === this.requestId() + ? command + : null; + }); + /** + * Property reviewing + * @readonly + * + * @description + * An explicit latest-version read is pending; it does not adopt that revision. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal} + */ + protected readonly reviewing = signal(false); + /** + * Property latest + * @readonly + * + * @description + * Latest server projection available for an explicit revision review. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal} + */ + protected readonly latest = signal(null); + /** + * Property busy + * @readonly + * + * @description + * An open editor or accepted mutation prevents a competing action. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly busy: Signal = computed( + () => + !this.store.commandsReady() || + this.store.writeCallState().status === 'pending' || + !!this.retainedConversion() || + this.editorVisible(), + ); + /** + * Property statusLabel + * @readonly + * + * @description + * Localized workflow label from the owning request vocabulary. + * + * @access protected + * @since unreleased + * + * @type {(status: ServiceRequestStatus) => string} + */ + protected readonly statusLabel = serviceRequestStatusLabel; + /** + * Property priorityLabel + * @readonly + * + * @description + * Localized declared-priority label. + * + * @access protected + * @since unreleased + * + * @type {(priority: ServiceRequestPriority) => string} + */ + protected readonly priorityLabel = serviceRequestPriorityLabel; + /** + * Property compatibleWork + * @readonly + * + * @description + * Live open repair tasks offered for explicit reuse; no work identity is fabricated. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly compatibleWork = computed(() => + (this.targets.openWorkCallState().data ?? []).filter( + (work) => + work.action === 'repair' && + (work.workItemStatus === 'planned' || work.workItemStatus === 'in_progress') && + !['submitted', 'published', 'abandoned', 'completed', 'closed', 'cancelled'].includes( + work.status, + ), + ), + ); + /** + * Property conflict + * @readonly + * + * @description + * A stale revision requires an explicit user review. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly conflict = computed( + () => + this.store.writeCallState().error?.code === 412 || + this.store.writeCallState().error?.code === '412', + ); + /** + * Property canAcceptLatest + * @readonly + * + * @description + * Whether the reviewed state still allows the originally selected action. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly canAcceptLatest = computed(() => { + const latest = this.latest(); + return !!latest && this.allowed(this.kind(), latest); + }); + //#endregion + + //#region Constructor + /** + * Constructor + * @constructor + * + * @description + * Binds scoped browser reads, explicit revision review and confirmed work-link feedback. + * + * @access public + * @since unreleased + */ + public constructor() { + effect(() => { + this.editorOwnerKey(); + const organizationId = this.organizationId(), + requestId = this.requestId(), + allowed = this.canRead(); + const enabled = allowed && isPlatformBrowser(this.platformId); + untracked(() => { + this.editorVisible.set(false); + this.editing.set(null); + this.latest.set(null); + this.reviewing.set(false); + this.targets.loadOpenWork(null); + this.store.read(enabled ? { organizationId, requestId } : null); + }); + }); + effect(() => { + const request = this.editing(), + kind = this.kind(), + visible = this.editorVisible(); + const enabled = + request?.equipmentId && + kind === 'convert' && + visible && + this.canReadWork() && + this.online() && + isPlatformBrowser(this.platformId); + untracked(() => + this.targets.loadOpenWork( + enabled && request + ? { organizationId: this.organizationId(), equipmentId: request.equipmentId ?? '' } + : null, + ), + ); + }); + effect(() => { + const reviewing = this.reviewing(), + state = this.store.readCallState(); + if (!reviewing || state.status !== 'success' || !state.data) return; + untracked(() => { + this.latest.set(state.data); + this.reviewing.set(false); + }); + }); + inject(Events) + .on(serviceRequestStoreEvents.saved) + .pipe(takeUntilDestroyed()) + .subscribe(({ payload }) => { + if ( + payload.organizationId !== this.organizationId() || + payload.request.id !== this.requestId() + ) + return; + this.editorVisible.set(false); + this.editing.set(null); + this.latest.set(null); + }); + } + //#endregion + + //#region Methods + /** + * Method canLeaveDraft + * + * @description + * Protects accepted writes and ordinary drafts while the session remains established. Journal + * restoration does not trap a reader, and durable uncertain conversions remain recoverable. + * + * @access public + * @since unreleased + * + * @returns {boolean | Promise} Whether leaving can preserve the current workflow. + */ + public canLeaveDraft(): boolean | Promise { + if (!this.isAuthenticated()) return true; + if (this.store.writeCallState().status === 'pending') return false; + if (!this.editorVisible()) return true; + if (this.retainedConversion() && this.store.conversionUncertain()) return true; + return this.editorSheet()?.canClose() ?? true; + } + + /** + * Method allowed + * + * @description + * Checks the dedicated management grant and the server state before offering an action. + * + * @access protected + * @since unreleased + * + * @param {ServiceRequestEditorKind} kind - Explicit workflow action. + * @param {ServiceRequestOutput} request - Displayed immutable request revision. + * + * @returns {boolean} Result owned by the request workflow. + */ + protected allowed(kind: ServiceRequestEditorKind, request: ServiceRequestOutput): boolean { + if (!this.canManage()) return false; + switch (kind) { + case 'create': + return false; + case 'update': + return request.status === 'requested'; + case 'qualify': + return request.status === 'requested' && (!!request.equipmentId || this.canReadEquipment()); + case 'reject': + case 'cancel': + return request.status === 'requested' || request.status === 'qualified'; + case 'convert': + return request.status === 'qualified' && !!request.equipmentId && this.canPlan(); + } + } + /** + * Method open + * + * @description + * Captures the displayed revision and opens an explicitly permitted action. + * + * @access protected + * @since unreleased + * + * @param {ServiceRequestEditorKind} kind - Explicit workflow action. + * + * @returns {void} Result owned by the request workflow. + */ + protected open(kind: ServiceRequestEditorKind): void { + const request = this.current(); + if (!request || this.busy() || !this.online() || !this.allowed(kind, request)) return; + this.store.clearWrite(); + this.latest.set(null); + this.editing.set(request); + this.kind.set(kind); + this.editorVisible.set(true); + } + /** + * Method update + * + * @description + * Submits editable description against the captured request revision. + * + * @access protected + * @since unreleased + * + * @param {CreateServiceRequestInput} data - Validated description fields. + * + * @returns {void} Result owned by the request workflow. + */ + protected update(data: CreateServiceRequestInput): void { + const request = this.editing(); + if ( + !request || + !this.online() || + !this.store.commandsReady() || + !!this.retainedConversion() || + this.store.writeCallState().status === 'pending' || + !this.allowed('update', request) + ) + return; + this.store.write({ + kind: 'update', + organizationId: this.organizationId(), + request, + input: { title: data.title, description: data.description, priority: data.priority }, + }); + } + /** + * Method act + * + * @description + * Builds the chosen explicit command; conversion uses only real existing work identities. + * + * @access protected + * @since unreleased + * + * @param {ServiceRequestActionIntent} intent - Validated explicit form intent. + * + * @returns {void} Result owned by the request workflow. + */ + protected act(intent: ServiceRequestActionIntent): void { + const request = this.editing(); + if ( + !request || + !this.online() || + !this.store.commandsReady() || + !!this.retainedConversion() || + this.store.writeCallState().status === 'pending' || + !this.allowed(intent.kind, request) + ) + return; + if (intent.kind === 'convert') { + this.store.write({ + kind: 'convert', + organizationId: this.organizationId(), + request, + input: { + clientOperationId: crypto.randomUUID(), + ...(intent.existingWork + ? { + existingInterventionId: intent.existingWork.interventionId, + existingTaskId: intent.existingWork.workItemId, + } + : {}), + }, + }); + } else if (intent.kind === 'qualify') + this.store.write({ + kind: 'qualify', + organizationId: this.organizationId(), + request, + input: intent.input, + }); + else + this.store.write({ + kind: intent.kind, + organizationId: this.organizationId(), + request, + input: intent.input, + }); + } + /** + * Method retryConversion + * + * @description + * Replays the exact retained conversion revision and payload after an uncertain response. + * + * @access protected + * @since unreleased + * + * @returns {void} Result owned by the request workflow. + */ + protected retryConversion(): void { + const command = this.store.conversionCommand(); + if ( + command && + command.organizationId === this.organizationId() && + command.request.id === this.requestId() && + this.store.commandsReady() && + this.store.writeCallState().status !== 'pending' && + this.online() && + this.canManage() + ) + this.store.write(command); + } + /** + * Method review + * + * @description + * Reads the latest server projection without automatically adopting its revision. + * + * @access protected + * @since unreleased + * + * @returns {void} Result owned by the request workflow. + */ + protected review(): void { + if (!this.canRead() || !this.online() || !this.conflict()) return; + this.latest.set(null); + this.reviewing.set(true); + this.store.read({ organizationId: this.organizationId(), requestId: this.requestId() }); + } + /** + * Method acceptRevision + * + * @description + * Adopts the explicitly reviewed revision only if it still allows the prepared action. + * + * @access protected + * @since unreleased + * + * @returns {void} Result owned by the request workflow. + */ + protected acceptRevision(): void { + const latest = this.latest(); + if (!latest || !this.canAcceptLatest() || this.store.conversionUncertain()) return; + this.editing.set(latest); + this.store.clearWrite(); + this.latest.set(null); + } + /** + * Method retry + * + * @description + * Retries the currently permitted scoped resource read. + * + * @access protected + * @since unreleased + * + * @returns {void} Result owned by the request workflow. + */ + protected retry(): void { + if (this.canRead() && this.online()) + this.store.read({ organizationId: this.organizationId(), requestId: this.requestId() }); + } + /** + * Method retryWork + * + * @description + * Retries the authorized open corrective-work projection for the captured equipment. + * + * @access protected + * @since unreleased + * + * @returns {void} Result owned by the request workflow. + */ + protected retryWork(): void { + const request = this.editing(); + if (request?.equipmentId && this.canReadWork() && this.online()) + this.targets.loadOpenWork({ + organizationId: this.organizationId(), + equipmentId: request.equipmentId, + }); + } + //#endregion +} diff --git a/src/app/features/organization/features/service-requests/ui/pages/service-request-detail-page/testing/service-request-detail-page.component.spec.ts b/src/app/features/organization/features/service-requests/ui/pages/service-request-detail-page/testing/service-request-detail-page.component.spec.ts new file mode 100644 index 000000000..4935f26fd --- /dev/null +++ b/src/app/features/organization/features/service-requests/ui/pages/service-request-detail-page/testing/service-request-detail-page.component.spec.ts @@ -0,0 +1,452 @@ +import { HttpErrorResponse } from '@angular/common/http'; +import { PLATFORM_ID, computed, signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { provideRouter } from '@angular/router'; +import { of, Subject, throwError, type Observable } from 'rxjs'; +import { ConnectivityService } from '@core/connectivity'; +import { INTERACTION_CAPABILITIES_PORT } from '@core/interaction-capabilities'; +import { THEME_PORT } from '@core/theme'; +import { OrganizationPermissionService } from '@features/organization/access'; +import { + EquipmentService, + EquipmentTypeService, +} from '@features/organization/features/equipments/data-access'; +import type { EquipmentOpenWorkOutput } from '@features/organization/features/equipments/models'; +import { FacilityService } from '@features/organization/features/facilities/data-access'; +import { ServiceRequestService } from '@features/organization/features/service-requests/data-access'; +import type { ServiceRequestOutput } from '@features/organization/features/service-requests/models'; +import { serviceRequestFixture } from '@features/organization/features/service-requests/models/service-request/testing/service-request.fixture'; +import { serviceRequestContext } from '@features/organization/features/service-requests/state/service-request/testing/service-request-context.fixture'; +import { ORGANIZATION_PERMISSION } from '@features/organization/models'; +import { REGIONAL_FORMATTING_PORT } from '@features/organization/ports'; +import { DEFAULT_REGIONAL_FORMAT_SETTINGS } from '@shared/regional-format'; +import { ServiceRequestEditorSheet } from '../../../sheets/service-request-editor-sheet/service-request-editor-sheet.component'; +import { ServiceRequestDetailPage } from '../service-request-detail-page.component'; + +describe('ServiceRequestDetailPage', () => { + const read = ORGANIZATION_PERMISSION.SERVICE_REQUESTS_READ; + const manage = ORGANIZATION_PERMISSION.SERVICE_REQUESTS_MANAGE; + const plan = ORGANIZATION_PERMISSION.INTERVENTIONS_PLAN; + + /** + * Function render + * + * @description + * Builds a page with real stores while keeping the editor as an input/output boundary. + */ + async function render( + request = serviceRequestFixture(), + options: { + readonly platform?: string; + readonly permissions?: readonly string[]; + readonly response?: Observable; + readonly openWork?: readonly EquipmentOpenWorkOutput[]; + readonly journalError?: Error; + } = {}, + ) { + const permissions = signal>( + new Set(options.permissions ?? [read, manage, plan]), + ); + const context = serviceRequestContext(computed(() => [...permissions()])); + if (options.platform === 'server') context.journal.browser = false; + if (options.journalError) context.journal.readPending.mockRejectedValue(options.journalError); + const online = signal(true); + const api = { + list: vi.fn(), + get: vi.fn().mockReturnValue(options.response ?? of(request)), + create: vi.fn(), + update: vi.fn().mockReturnValue(of(request)), + qualify: vi.fn().mockReturnValue(of(request)), + reject: vi.fn().mockReturnValue(of(request)), + cancel: vi.fn().mockReturnValue(of(request)), + convert: vi.fn().mockReturnValue(of(request)), + }; + const equipment = { + list: vi.fn().mockReturnValue(of({ member: [], totalItems: 0 })), + listByFacility: vi.fn().mockReturnValue(of({ member: [], totalItems: 0 })), + get: vi.fn(), + openWork: vi + .fn() + .mockReturnValue( + of({ member: options.openWork ?? [], totalItems: options.openWork?.length ?? 0 }), + ), + }; + const facilities = { + list: vi.fn().mockReturnValue(of({ member: [], totalItems: 0 })), + get: vi.fn(), + }; + const types = { + listAll: vi.fn().mockReturnValue(of([])), + list: vi.fn().mockReturnValue(of({ member: [], totalItems: 0 })), + }; + TestBed.configureTestingModule({ + imports: [ServiceRequestDetailPage], + providers: [ + provideRouter([]), + ...context.providers, + { provide: PLATFORM_ID, useValue: options.platform ?? 'browser' }, + { provide: ServiceRequestService, useValue: api }, + { provide: EquipmentService, useValue: equipment }, + { provide: EquipmentTypeService, useValue: types }, + { provide: FacilityService, useValue: facilities }, + { provide: THEME_PORT, useValue: { resolvedTheme: signal('light') } }, + { provide: ConnectivityService, useValue: { isOnline: online } }, + { + provide: OrganizationPermissionService, + useValue: { hasPermission: (permission: string) => permissions().has(permission) }, + }, + { + provide: REGIONAL_FORMATTING_PORT, + useValue: { regionalFormatting: signal(DEFAULT_REGIONAL_FORMAT_SETTINGS) }, + }, + { + provide: INTERACTION_CAPABILITIES_PORT, + useValue: { + isMobileInteractionMode: signal(false), + interactionMode: signal('desktop'), + shortcutModifier: signal('Control'), + }, + }, + ], + }); + TestBed.overrideComponent(ServiceRequestEditorSheet, { set: { template: '', imports: [] } }); + const fixture = TestBed.createComponent(ServiceRequestDetailPage); + fixture.componentRef.setInput('organizationId', request.organizationId); + fixture.componentRef.setInput('requestId', request.id); + await fixture.whenStable(); + const element = fixture.nativeElement as HTMLElement; + const button = (label: string) => + [...element.querySelectorAll('button')].find( + (candidate) => candidate.textContent?.trim() === label, + ); + return { + fixture, + element, + get sheet(): ServiceRequestEditorSheet { + return fixture.debugElement.query(By.directive(ServiceRequestEditorSheet)) + .componentInstance as ServiceRequestEditorSheet; + }, + api, + equipment, + facilities, + types, + permissions, + online, + button, + context, + }; + } + + it('does not request authenticated detail or picker data during SSR', async () => { + const page = await render(serviceRequestFixture(), { + platform: 'server', + permissions: [ + read, + manage, + plan, + ORGANIZATION_PERMISSION.EQUIPMENT_READ, + ORGANIZATION_PERMISSION.FACILITIES_READ, + ], + }); + expect(page.api.get).not.toHaveBeenCalled(); + expect(page.equipment.list).not.toHaveBeenCalled(); + expect(page.equipment.get).not.toHaveBeenCalled(); + expect(page.equipment.openWork).not.toHaveBeenCalled(); + expect(page.facilities.list).not.toHaveBeenCalled(); + expect(page.types.listAll).not.toHaveBeenCalled(); + expect(page.sheet.visible()).toBe(false); + }); + + it('keeps actions blocked after journal hydration fails while permitting readers to leave', async () => { + const page = await render(serviceRequestFixture(), { + journalError: new Error('Storage unavailable'), + }); + expect( + page.element.querySelector('[data-testid="service-request-command-restore-error"]'), + ).not.toBeNull(); + expect(page.button('Edit')?.disabled).toBe(true); + expect(page.fixture.componentInstance.canLeaveDraft()).toBe(true); + expect(page.api.update).not.toHaveBeenCalled(); + expect(page.api.convert).not.toHaveBeenCalled(); + page.context.journal.readPending.mockResolvedValue([]); + const retry = page.element.querySelector( + '[data-testid="service-request-command-restore-error"] button', + ); + if (!retry) throw new Error('The journal recovery action is missing.'); + retry.click(); + await page.fixture.whenStable(); + expect( + page.element.querySelector('[data-testid="service-request-command-restore-error"]'), + ).toBeNull(); + expect(page.button('Edit')?.disabled).toBe(false); + expect(page.fixture.componentInstance.canLeaveDraft()).toBe(true); + }); + + it('does not turn create permission into detail management permission', async () => { + const page = await render(serviceRequestFixture(), { + permissions: [read, ORGANIZATION_PERMISSION.SERVICE_REQUESTS_CREATE], + }); + expect(page.api.get).toHaveBeenCalledWith('org', 'request'); + expect(page.button('Edit')).toBeUndefined(); + expect(page.element.querySelector('[data-testid="service-request-qualify"]')).toBeNull(); + expect(page.button('Reject request')).toBeUndefined(); + expect(page.button('Cancel request')).toBeUndefined(); + }); + + it('permits qualification for a manager but requires planning permission for conversion', async () => { + const page = await render(serviceRequestFixture(), { permissions: [read, manage] }); + expect(page.element.querySelector('[data-testid="service-request-qualify"]')).not.toBeNull(); + page.api.get.mockReturnValue( + of(serviceRequestFixture({ id: 'qualified-request', status: 'qualified', revision: 2 })), + ); + page.fixture.componentRef.setInput('requestId', 'qualified-request'); + await page.fixture.whenStable(); + expect(page.element.querySelector('[data-testid="service-request-convert"]')).toBeNull(); + page.permissions.set(new Set([read, manage, plan])); + await page.fixture.whenStable(); + expect(page.element.querySelector('[data-testid="service-request-convert"]')).not.toBeNull(); + }); + + it('keeps reported identity visible while gating every dossier link by its read permission', async () => { + const request = serviceRequestFixture({ + status: 'converted', + interventionId: 'work', + taskId: 'task', + originInspectionId: 'inspection', + }); + const page = await render(request, { permissions: [read] }); + expect(page.element.textContent).toContain('Extinguisher A'); + expect(page.element.textContent).toContain('Hospital operator'); + const hrefs = () => + [...page.element.querySelectorAll('a')].map((anchor) => + anchor.getAttribute('href'), + ); + expect(hrefs()).not.toContain('/organizations/org/equipments/equipment'); + expect(hrefs()).not.toContain('/organizations/org/facilities/site'); + expect(hrefs()).not.toContain('/organizations/org/inspections/inspection'); + expect(hrefs().some((href) => href?.startsWith('/organizations/org/interventions/work'))).toBe( + false, + ); + page.permissions.set( + new Set([ + read, + ORGANIZATION_PERMISSION.EQUIPMENT_READ, + ORGANIZATION_PERMISSION.FACILITIES_READ, + ORGANIZATION_PERMISSION.INSPECTION_READ, + ORGANIZATION_PERMISSION.INTERVENTIONS_READ, + ]), + ); + await page.fixture.whenStable(); + expect(hrefs()).toContain('/organizations/org/equipments/equipment'); + expect(hrefs()).toContain('/organizations/org/facilities/site'); + expect(hrefs()).toContain('/organizations/org/inspections/inspection'); + expect(hrefs()).toContain( + '/organizations/org/interventions/work?targetEquipment=equipment&workAction=repair', + ); + }); + + it('keeps the old edit revision after 412 and after refresh until explicit acceptance', async () => { + const original = serviceRequestFixture(); + const latest = serviceRequestFixture({ revision: 7, title: 'Server revision' }); + const page = await render(original); + const draft = { + title: 'My retained draft', + description: 'Repair the damaged gauge.', + priority: 'high' as const, + equipmentId: 'equipment', + }; + page.api.update.mockReturnValue( + throwError( + () => + new HttpErrorResponse({ status: 412, error: { detail: 'Review the current version.' } }), + ), + ); + page.button('Edit')?.click(); + await page.fixture.whenStable(); + page.sheet.descriptionSubmitted.emit(draft); + await page.fixture.whenStable(); + expect(page.sheet.conflict()).toBe(true); + expect(page.sheet.request()?.revision).toBe(1); + expect(page.api.get).toHaveBeenCalledTimes(1); + page.api.get.mockReturnValue(of(latest)); + page.sheet.refreshRequested.emit(); + await page.fixture.whenStable(); + expect(page.sheet.latestRequest()?.revision).toBe(7); + expect(page.sheet.request()?.revision).toBe(1); + expect(page.sheet.conflict()).toBe(true); + expect(page.sheet.visible()).toBe(true); + page.sheet.descriptionSubmitted.emit(draft); + await page.fixture.whenStable(); + expect(page.api.update).toHaveBeenLastCalledWith('org', original, { + title: draft.title, + description: draft.description, + priority: draft.priority, + }); + page.sheet.revisionAccepted.emit(); + await page.fixture.whenStable(); + expect(page.sheet.request()?.revision).toBe(7); + expect(page.sheet.conflict()).toBe(false); + page.sheet.descriptionSubmitted.emit(draft); + await page.fixture.whenStable(); + expect(page.api.update).toHaveBeenLastCalledWith('org', latest, { + title: draft.title, + description: draft.description, + priority: draft.priority, + }); + }); + + it('does not adopt a reviewed terminal revision for an edit that is no longer allowed', async () => { + const page = await render(); + page.api.update.mockReturnValue(throwError(() => new HttpErrorResponse({ status: 412 }))); + page.button('Edit')?.click(); + await page.fixture.whenStable(); + page.sheet.descriptionSubmitted.emit({ + title: 'Draft', + description: 'Still needed', + equipmentId: 'equipment', + }); + await page.fixture.whenStable(); + page.api.get.mockReturnValue( + of( + serviceRequestFixture({ + status: 'converted', + revision: 3, + interventionId: 'work', + taskId: 'task', + }), + ), + ); + page.sheet.refreshRequested.emit(); + await page.fixture.whenStable(); + expect(page.sheet.canAcceptRevision()).toBe(false); + page.sheet.revisionAccepted.emit(); + await page.fixture.whenStable(); + expect(page.sheet.request()?.revision).toBe(1); + expect(page.sheet.conflict()).toBe(true); + }); + + it('offers API-shaped planned repairs and converts using the retained task instead of creating new work', async () => { + const repair: EquipmentOpenWorkOutput = { + '@id': '/api/intervention-work-items/existing-task', + '@type': 'EquipmentOpenWork', + interventionId: 'existing-intervention', + workItemId: 'existing-task', + name: 'Planned gauge repair', + number: 42, + status: 'planned', + action: 'repair', + workItemStatus: 'planned', + }; + const request = serviceRequestFixture({ status: 'qualified', revision: 2 }); + const page = await render(request, { + permissions: [ + read, + manage, + plan, + ORGANIZATION_PERMISSION.EQUIPMENT_READ, + ORGANIZATION_PERMISSION.INTERVENTIONS_READ, + ], + openWork: [ + repair, + { ...repair, workItemId: 'finished', workItemStatus: 'completed' }, + { ...repair, workItemId: 'control', action: 'inspect' }, + ], + }); + ( + page.element.querySelector('[data-testid="service-request-convert"]') as HTMLButtonElement + ).click(); + await page.fixture.whenStable(); + expect(page.sheet.openWork()).toEqual([repair]); + page.sheet.actionSubmitted.emit({ kind: 'convert', existingWork: repair }); + await page.fixture.whenStable(); + expect(page.api.convert).toHaveBeenCalledOnce(); + expect(page.api.convert).toHaveBeenCalledWith('org', request, { + clientOperationId: expect.any(String), + existingInterventionId: 'existing-intervention', + existingTaskId: 'existing-task', + }); + }); + + it('checks planning rights again when the conversion editor emits its action', async () => { + const page = await render(serviceRequestFixture({ status: 'qualified', revision: 2 })); + ( + page.element.querySelector('[data-testid="service-request-convert"]') as HTMLButtonElement + ).click(); + await page.fixture.whenStable(); + expect(page.sheet.visible()).toBe(true); + page.permissions.set(new Set([read, manage])); + await page.fixture.whenStable(); + page.sheet.actionSubmitted.emit({ kind: 'convert', existingWork: null }); + await page.fixture.whenStable(); + expect(page.api.convert).not.toHaveBeenCalled(); + }); + + it('cancels obsolete route reads and leaves old target responses out of the page', async () => { + const old = new Subject(); + const page = await render(serviceRequestFixture(), { response: old }); + const current = serviceRequestFixture({ id: 'other', title: 'Current request' }); + page.api.get.mockReturnValue(of(current)); + page.fixture.componentRef.setInput('requestId', 'other'); + await page.fixture.whenStable(); + expect(old.observed).toBe(false); + old.next(serviceRequestFixture({ title: 'Obsolete request' })); + await page.fixture.whenStable(); + expect(page.element.textContent).toContain('Current request'); + expect(page.element.textContent).not.toContain('Obsolete request'); + expect(page.api.get).toHaveBeenLastCalledWith('org', 'other'); + }); + + it('replays the retained conversion after planning rights are revoked but still requires management rights', async () => { + const page = await render(serviceRequestFixture({ status: 'qualified', revision: 2 })); + page.api.convert.mockReturnValue(throwError(() => new HttpErrorResponse({ status: 0 }))); + ( + page.element.querySelector('[data-testid="service-request-convert"]') as HTMLButtonElement + ).click(); + await page.fixture.whenStable(); + page.sheet.actionSubmitted.emit({ kind: 'convert', existingWork: null }); + await page.fixture.whenStable(); + expect(page.sheet.uncertain()).toBe(true); + expect(page.api.convert).toHaveBeenCalledTimes(1); + expect(page.fixture.componentInstance.canLeaveDraft()).toBe(true); + const originalCommand = page.api.convert.mock.calls[0]; + page.permissions.set(new Set([read, manage])); + await page.fixture.whenStable(); + page.sheet.retryConversionRequested.emit(); + await page.fixture.whenStable(); + expect(page.api.convert).toHaveBeenCalledTimes(2); + expect(page.api.convert.mock.calls[1]).toEqual(originalCommand); + page.permissions.set(new Set([read])); + await page.fixture.whenStable(); + page.sheet.retryConversionRequested.emit(); + await page.fixture.whenStable(); + expect(page.api.convert).toHaveBeenCalledTimes(2); + expect(page.sheet.uncertain()).toBe(false); + }); + + it('lets an accepted old write finish without restoring its editor in a new route scope', async () => { + const page = await render(); + const accepted = new Subject(); + page.api.qualify.mockReturnValue(accepted); + ( + page.element.querySelector('[data-testid="service-request-qualify"]') as HTMLButtonElement + ).click(); + await page.fixture.whenStable(); + page.sheet.actionSubmitted.emit({ kind: 'qualify', input: { note: 'Inspect first.' } }); + await page.fixture.whenStable(); + expect(accepted.observed).toBe(true); + expect(page.fixture.componentInstance.canLeaveDraft()).toBe(false); + page.api.get.mockReturnValue( + of(serviceRequestFixture({ id: 'other', title: 'Current request' })), + ); + page.fixture.componentRef.setInput('requestId', 'other'); + await page.fixture.whenStable(); + expect(accepted.observed).toBe(true); + accepted.next(serviceRequestFixture({ status: 'qualified', revision: 2 })); + accepted.complete(); + await page.fixture.whenStable(); + expect(page.element.textContent).toContain('Current request'); + expect(page.sheet.visible()).toBe(false); + expect(page.sheet.request()).toBeNull(); + }); +}); diff --git a/src/app/features/organization/features/service-requests/ui/pages/service-request-detail-page/testing/service-request-detail-page.recovery.spec.ts b/src/app/features/organization/features/service-requests/ui/pages/service-request-detail-page/testing/service-request-detail-page.recovery.spec.ts new file mode 100644 index 000000000..03814bb0c --- /dev/null +++ b/src/app/features/organization/features/service-requests/ui/pages/service-request-detail-page/testing/service-request-detail-page.recovery.spec.ts @@ -0,0 +1,163 @@ +import { HttpErrorResponse } from '@angular/common/http'; +import { ChangeDetectionStrategy, Component, DestroyRef, signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { provideRouter, withComponentInputBinding } from '@angular/router'; +import { RouterTestingHarness } from '@angular/router/testing'; +import { of, throwError } from 'rxjs'; +import { ConnectivityService } from '@core/connectivity'; +import { INTERACTION_CAPABILITIES_PORT } from '@core/interaction-capabilities'; +import { THEME_PORT } from '@core/theme'; +import { OrganizationPermissionService } from '@features/organization/access'; +import { + EquipmentService, + EquipmentTypeService, +} from '@features/organization/features/equipments/data-access'; +import { FacilityService } from '@features/organization/features/facilities/data-access'; +import { ServiceRequestService } from '@features/organization/features/service-requests/data-access'; +import { serviceRequestDraftLeaveGuard } from '@features/organization/features/service-requests/http/guards'; +import { serviceRequestFixture } from '@features/organization/features/service-requests/models/service-request/testing/service-request.fixture'; +import { ServiceRequestStore } from '@features/organization/features/service-requests/state'; +import { serviceRequestContext } from '@features/organization/features/service-requests/state/service-request/testing/service-request-context.fixture'; +import { ORGANIZATION_PERMISSION } from '@features/organization/models'; +import { REGIONAL_FORMATTING_PORT } from '@features/organization/ports'; +import { DEFAULT_REGIONAL_FORMAT_SETTINGS } from '@shared/regional-format'; +import { ServiceRequestEditorSheet } from '../../../sheets/service-request-editor-sheet/service-request-editor-sheet.component'; +import { ServiceRequestDetailPage } from '../service-request-detail-page.component'; + +@Component({ template: '', changeDetection: ChangeDetectionStrategy.OnPush }) +class OtherRoutePage {} + +describe('ServiceRequestDetailPage', () => { + it('destroys the routed page after an uncertain conversion and restores its exact UUID, revision and work choice on return', async () => { + const original = serviceRequestFixture({ status: 'qualified', revision: 7 }); + const grants = signal([ + ORGANIZATION_PERMISSION.SERVICE_REQUESTS_READ, + ORGANIZATION_PERMISSION.SERVICE_REQUESTS_MANAGE, + ORGANIZATION_PERMISSION.INTERVENTIONS_PLAN, + ]); + const context = serviceRequestContext(grants); + const api = { + get: vi.fn().mockReturnValue(of(original)), + convert: vi.fn().mockReturnValue(throwError(() => new HttpErrorResponse({ status: 0 }))), + }; + TestBed.configureTestingModule({ + providers: [ + ...context.providers, + provideRouter( + [ + { + path: 'organizations/:organizationId/service-requests/:requestId', + component: ServiceRequestDetailPage, + canDeactivate: [serviceRequestDraftLeaveGuard], + }, + { path: 'other', component: OtherRoutePage }, + ], + withComponentInputBinding(), + ), + { provide: ServiceRequestService, useValue: api }, + { provide: EquipmentService, useValue: { list: vi.fn(), get: vi.fn(), openWork: vi.fn() } }, + { provide: EquipmentTypeService, useValue: { listAll: vi.fn() } }, + { provide: FacilityService, useValue: { list: vi.fn(), get: vi.fn() } }, + { provide: ConnectivityService, useValue: { isOnline: signal(true) } }, + { + provide: OrganizationPermissionService, + useValue: { hasPermission: (permission: string) => grants().includes(permission) }, + }, + { provide: THEME_PORT, useValue: { resolvedTheme: signal('light') } }, + { + provide: REGIONAL_FORMATTING_PORT, + useValue: { regionalFormatting: signal(DEFAULT_REGIONAL_FORMAT_SETTINGS) }, + }, + { + provide: INTERACTION_CAPABILITIES_PORT, + useValue: { + isMobileInteractionMode: signal(false), + interactionMode: signal('desktop'), + shortcutModifier: signal('Control'), + }, + }, + ], + }); + TestBed.overrideComponent(ServiceRequestEditorSheet, { set: { template: '', imports: [] } }); + const harness = await RouterTestingHarness.create(); + const first = await harness.navigateByUrl( + '/organizations/org/service-requests/request', + ServiceRequestDetailPage, + ); + await harness.fixture.whenStable(); + const routed = harness.routeDebugElement; + if (!routed) throw new Error('Missing routed request page'); + const originalStore = routed.injector.get(ServiceRequestStore); + const destroyed = vi.fn(); + routed.injector.get(DestroyRef).onDestroy(destroyed); + harness.routeNativeElement + ?.querySelector('[data-testid="service-request-convert"]') + ?.click(); + await harness.fixture.whenStable(); + const sheet = routed.query(By.directive(ServiceRequestEditorSheet)) + .componentInstance as ServiceRequestEditorSheet; + sheet.actionSubmitted.emit({ + kind: 'convert', + existingWork: { + '@id': '/work', + '@type': 'EquipmentOpenWork', + interventionId: 'original-work', + workItemId: 'original-task', + name: 'Existing repair', + number: 42, + status: 'planned', + action: 'repair', + workItemStatus: 'planned', + }, + }); + await vi.waitFor(() => expect(originalStore.conversionUncertain()).toBe(true)); + const firstCall = api.convert.mock.calls[0]; + expect(firstCall).toEqual([ + 'org', + original, + { + clientOperationId: expect.any(String), + existingInterventionId: 'original-work', + existingTaskId: 'original-task', + }, + ]); + await harness.navigateByUrl('/other', OtherRoutePage); + expect(destroyed).toHaveBeenCalledOnce(); + expect(context.commands.size).toBe(1); + api.get.mockReturnValue(of(serviceRequestFixture({ status: 'qualified', revision: 99 }))); + grants.set([ + ORGANIZATION_PERMISSION.SERVICE_REQUESTS_READ, + ORGANIZATION_PERMISSION.SERVICE_REQUESTS_MANAGE, + ]); + const returned = await harness.navigateByUrl( + '/organizations/org/service-requests/request', + ServiceRequestDetailPage, + ); + await harness.fixture.whenStable(); + expect(returned).not.toBe(first); + const returnedStore = harness.routeDebugElement?.injector.get(ServiceRequestStore); + expect(returnedStore).not.toBe(originalStore); + await vi.waitFor(() => expect(returnedStore?.conversionUncertain()).toBe(true)); + expect(api.convert).toHaveBeenCalledOnce(); + const retry = harness.routeNativeElement?.querySelector( + '[data-testid="service-request-conversion-recovery"] button', + ); + expect(retry?.disabled).toBe(false); + api.convert.mockReturnValue( + of( + serviceRequestFixture({ + status: 'converted', + revision: 8, + interventionId: 'original-work', + taskId: 'original-task', + }), + ), + ); + retry?.click(); + await vi.waitFor(() => expect(api.convert).toHaveBeenCalledTimes(2)); + expect(api.convert.mock.calls[1]).toEqual(firstCall); + await vi.waitFor(() => expect(context.commands.size).toBe(0)); + expect(returnedStore?.readCallState().data?.interventionId).toBe('original-work'); + }); +}); diff --git a/src/app/features/organization/features/service-requests/ui/pages/service-requests-page/service-requests-page.component.html b/src/app/features/organization/features/service-requests/ui/pages/service-requests-page/service-requests-page.component.html new file mode 100644 index 000000000..16523857d --- /dev/null +++ b/src/app/features/organization/features/service-requests/ui/pages/service-requests-page/service-requests-page.component.html @@ -0,0 +1,267 @@ +
+
+

+ Describe maintenance needs, qualify their target, then organize corrective work. +

+ @if (canCreate()) { + + } +
+ @if (store.commandCallState().error) { +
+

+ Saved maintenance operations could not be restored. Retry before starting another action. +

+ +
+ } + @if (confirmedTitle()) { +
+

+ Maintenance request submitted: {{ confirmedTitle() }} +

+
+ } + @if (!online()) { +
+

+ You are offline. Your open draft is kept until you can submit it. +

+
+ } + @if (canRead()) { +
+ + + + @for (status of statuses; track status) { + + } + +
+ @if (store.listCallState().status === 'pending') { +
+ +
+ } @else if (store.listCallState().error; as error) { +
+
+

Requests could not be loaded

+

{{ error.message }}

+
+
+ +
+
+ } @else if ( + store.listCallState().status === 'success' && store.requestEntities().length === 0 + ) { +
+
+
+ +
+

No maintenance requests

+

+ The current target and filters have no requests to display. +

+
+
+ @if (search() || status()) { + + } @else if (canCreate()) { + + } +
+
+ } @else if (store.requestEntities().length) { + @if (isMobile()) { +
+ @for (request of store.requestEntities(); track request.id) { +
+
+ {{ request.title }} +

+ {{ + request.targetSnapshot.equipment?.name ?? + request.targetSnapshot.equipment?.assetCode ?? + request.targetSnapshot.site?.name + }} +

+

+ {{ request.requestedAt | appOrgDate: 'datetime' : regionalFormatting() }} +

+
+
+ {{ statusLabel(request.status) }}{{ priorityLabel(request.priority) }} +
+
+ } +
+ } @else { +
+ + + + + + + + + + + + @for (request of store.requestEntities(); track request.id) { + + + + + + + + } + +
Title + Maintenance target + + Request status + PriorityRequested
+ {{ request.title }} + + {{ + request.targetSnapshot.equipment?.name ?? + request.targetSnapshot.equipment?.assetCode ?? + request.targetSnapshot.site?.name + }}{{ + request.targetSnapshot.customer?.name + }} + + {{ statusLabel(request.status) }} + {{ priorityLabel(request.priority) }} + {{ request.requestedAt | appOrgDate: 'datetime' : regionalFormatting() }} +
+
+ } + + } + } + @for (owner of [editorOwnerKey()]; track owner) { + + } +
diff --git a/src/app/features/organization/features/service-requests/ui/pages/service-requests-page/service-requests-page.component.ts b/src/app/features/organization/features/service-requests/ui/pages/service-requests-page/service-requests-page.component.ts new file mode 100644 index 000000000..64e7fe63e --- /dev/null +++ b/src/app/features/organization/features/service-requests/ui/pages/service-requests-page/service-requests-page.component.ts @@ -0,0 +1,698 @@ +import { isPlatformBrowser } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + PLATFORM_ID, + computed, + effect, + inject, + input, + signal, + untracked, + viewChild, + type Signal, +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { Router, RouterLink } from '@angular/router'; +import { Events } from '@ngrx/signals/events'; +import { ConnectivityService } from '@core/connectivity'; +import { INTERACTION_CAPABILITIES_PORT } from '@core/interaction-capabilities'; +import { AUTH_SESSION_PORT } from '@features/auth/ports'; +import { OrganizationPermissionService } from '@features/organization/access'; +import type { + ServiceRequestStatus, + CreateServiceRequestInput, +} from '@features/organization/features/service-requests/models'; +import { + ServiceRequestStore, + serviceRequestStoreEvents, +} from '@features/organization/features/service-requests/state'; +import { + serviceRequestStatusLabel, + serviceRequestPriorityLabel, +} from '@features/organization/features/service-requests/utils'; +import { ORGANIZATION_PERMISSION } from '@features/organization/models'; +import { + ORGANIZATION_MEMBER_ACCESS_PORT, + REGIONAL_FORMATTING_PORT, + type OrganizationMemberAccessPort, +} from '@features/organization/ports'; +import { CollectionPagination } from '@shared/collection-pagination'; +import { OrgDatePipe } from '@shared/regional-format'; +import { StateIllustration } from '@shared/state-illustration'; +import { HlmAlertImports } from '@shared/ui/alert'; +import { HlmBadge } from '@shared/ui/badge'; +import { HlmButton } from '@shared/ui/button'; +import { HlmEmptyImports } from '@shared/ui/empty'; +import { HlmInput } from '@shared/ui/input'; +import { HlmItemImports } from '@shared/ui/item'; +import { HlmSkeleton } from '@shared/ui/skeleton'; +import { HlmTableImports } from '@shared/ui/table'; +import { HlmToggleGroupImports } from '@shared/ui/toggle-group'; +import { ServiceRequestEditorSheet } from '../../sheets/service-request-editor-sheet/service-request-editor-sheet.component'; + +/** + * Class ServiceRequestsPage + * @class ServiceRequestsPage + * + * @description + * Internal server-paginated request directory and dedicated creation entry point. + * + * @since unreleased + */ +@Component({ + selector: 'app-service-requests-page', + templateUrl: './service-requests-page.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ServiceRequestStore], + imports: [ + RouterLink, + OrgDatePipe, + CollectionPagination, + StateIllustration, + HlmBadge, + HlmButton, + HlmInput, + HlmSkeleton, + ServiceRequestEditorSheet, + ...HlmAlertImports, + ...HlmEmptyImports, + ...HlmItemImports, + ...HlmTableImports, + ...HlmToggleGroupImports, + ], +}) +export class ServiceRequestsPage { + //#region Properties + /** + * Property organizationId + * @readonly + * + * @description + * Organization authority shared by the owning route and server transport. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly organizationId = input.required(); + /** + * Property equipmentId + * @readonly + * + * @description + * Optional route equipment target; absent query parameters remain undefined. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly equipmentId = input(); + /** + * Property siteId + * @readonly + * + * @description + * Optional root-site route scope; qualification choices stay inside this site. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly siteId = input(); + /** + * Property originInspectionId + * @readonly + * + * @description + * Optional source inspection preserved in the creation input. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly originInspectionId = input(); + /** + * Property originNonConformityId + * @readonly + * + * @description + * Optional source anomaly preserved in the creation input. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly originNonConformityId = input(); + /** + * Property createParam + * @readonly + * + * @description + * Optional route intent to open creation without submitting a request. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly createParam = input(undefined, { alias: 'create' }); + /** + * Property store + * @readonly + * + * @description + * Component-owned request workflow state. + * + * @access protected + * @since unreleased + * + * @type {ServiceRequestStoreType} + */ + protected readonly store = inject(ServiceRequestStore); + /** + * Property sessionRevision + * @readonly + * + * @description + * Session replacement invalidates the route's actor-private editor without reading credentials. + * + * @access private + * @since unreleased + * + * @type {Signal} + */ + private readonly sessionRevision: Signal = inject(AUTH_SESSION_PORT).sessionRevision; + + /** + * Property isAuthenticated + * @readonly + * + * @description + * Established local session whose termination must permit authentication redirects. + * + * @access private + * @since unreleased + * + * @type {Signal} + */ + private readonly isAuthenticated: Signal = inject(AUTH_SESSION_PORT).isAuthenticated; + + /** + * Property memberAccess + * @readonly + * + * @description + * Published member identity distinguishes actors whose organization permissions are identical. + * + * @access private + * @since unreleased + * + * @type {OrganizationMemberAccessPort} + */ + private readonly memberAccess: OrganizationMemberAccessPort = inject( + ORGANIZATION_MEMBER_ACCESS_PORT, + ); + + /** + * Property editorOwnerKey + * @readonly + * + * @description + * Stable route, actor and session identity destroys the previous native editor when its owner + * changes. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly editorOwnerKey: Signal = computed(() => + JSON.stringify([ + this.organizationId(), + this.sessionRevision(), + this.memberAccess.profile()?.userId ?? null, + ]), + ); + /** + * Property permissions + * @readonly + * + * @description + * API-derived organization permission authority. + * + * @access private + * @since unreleased + * + * @type {OrganizationPermissionService} + */ + private readonly permissions = inject(OrganizationPermissionService); + /** + * Property connectivity + * @readonly + * + * @description + * Online state used without discarding an entered draft. + * + * @access private + * @since unreleased + * + * @type {ConnectivityService} + */ + private readonly connectivity = inject(ConnectivityService); + /** + * Property platformId + * @readonly + * + * @description + * Browser guard for secondary authenticated reads. + * + * @access private + * @since unreleased + * + * @type {object} + */ + private readonly platformId = inject(PLATFORM_ID); + /** + * Property router + * @readonly + * + * @description + * Navigation after the server confirms request creation. + * + * @access private + * @since unreleased + * + * @type {Router} + */ + private readonly router = inject(Router); + /** + * Property regionalFormatting + * @readonly + * + * @description + * Organization timezone and date pattern for retained history. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly regionalFormatting = inject(REGIONAL_FORMATTING_PORT).regionalFormatting; + /** + * Property isMobile + * @readonly + * + * @description + * Central interaction mode selects native touch cards without viewport heuristics. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly isMobile = inject(INTERACTION_CAPABILITIES_PORT).isMobileInteractionMode; + /** + * Property canRead + * @readonly + * + * @description + * Permission gate for the owned resource reads. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly canRead = computed(() => + this.permissions.hasPermission(ORGANIZATION_PERMISSION.SERVICE_REQUESTS_READ), + ); + /** + * Property canCreate + * @readonly + * + * @description + * Dedicated request creation grant, independent of management. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly canCreate = computed(() => + this.permissions.hasPermission(ORGANIZATION_PERMISSION.SERVICE_REQUESTS_CREATE), + ); + /** + * Property online + * @readonly + * + * @description + * Observed network state for explicit online commands. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly online = computed(() => this.connectivity.isOnline()); + /** + * Property page + * @readonly + * + * @description + * Current server page, starting at one. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal} + */ + protected readonly page = signal(1); + /** + * Property search + * @readonly + * + * @description + * Committed server search term. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal} + */ + protected readonly search = signal(''); + /** + * Property status + * @readonly + * + * @description + * Server workflow state or the selected workflow filter. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal} + */ + protected readonly status = signal(''); + /** + * Property editorVisible + * @readonly + * + * @description + * Overlay visibility owned by the page and confirmed mutation feedback. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal} + */ + protected readonly editorVisible = signal(false); + /** + * Property editorSheet + * @readonly + * + * @description + * Active native editor owns the entered draft and its discard confirmation. + * + * @access private + * @since unreleased + * + * @type {Signal} + */ + private readonly editorSheet: Signal = + viewChild(ServiceRequestEditorSheet); + /** + * Property confirmedTitle + * @readonly + * + * @description + * Server-confirmed creation title shown to a creator without directory read access. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal} + */ + protected readonly confirmedTitle = signal(''); + /** + * Property statusLabel + * @readonly + * + * @description + * Localized workflow label from the owning request vocabulary. + * + * @access protected + * @since unreleased + * + * @type {(status: ServiceRequestStatus) => string} + */ + protected readonly statusLabel = serviceRequestStatusLabel; + /** + * Property priorityLabel + * @readonly + * + * @description + * Localized declared-priority label. + * + * @access protected + * @since unreleased + * + * @type {(priority: ServiceRequestPriority) => string} + */ + protected readonly priorityLabel = serviceRequestPriorityLabel; + /** + * Property statuses + * @readonly + * + * @description + * Canonical server workflow filters offered to the directory. + * + * @access protected + * @since unreleased + * + * @type {readonly ServiceRequestStatus[]} + */ + protected readonly statuses: readonly ServiceRequestStatus[] = [ + 'requested', + 'qualified', + 'rejected', + 'cancelled', + 'converted', + ]; + //#endregion + + //#region Constructor + /** + * Constructor + * @constructor + * + * @description + * Binds cancellable browser directory reads, permission-gated creation and confirmed navigation. + * + * @access public + * @since unreleased + */ + public constructor() { + effect(() => { + const organizationId = this.organizationId(); + this.editorOwnerKey(); + untracked(() => { + this.page.set(1); + this.search.set(''); + this.status.set(''); + this.editorVisible.set(false); + this.confirmedTitle.set(''); + this.store.load(null); + }); + if (!organizationId) return; + }); + effect(() => { + this.editorOwnerKey(); + const organizationId = this.organizationId(), + equipmentId = this.equipmentId(), + siteId = this.siteId(), + page = this.page(), + search = this.search(), + status = this.status(); + const enabled = this.canRead() && this.online() && isPlatformBrowser(this.platformId); + const commandsEnabled = + (this.canRead() || this.canCreate()) && isPlatformBrowser(this.platformId); + untracked(() => { + this.store.load( + enabled + ? { + organizationId, + page, + search, + ...(status ? { status } : {}), + ...(equipmentId ? { equipmentId } : {}), + ...(siteId ? { siteId } : {}), + } + : null, + ); + if (commandsEnabled && organizationId) this.store.activateCommands(organizationId); + }); + }); + effect(() => { + const create = this.createParam(), + allowed = this.canCreate(), + organizationId = this.organizationId(); + if ( + create !== '1' || + !allowed || + !organizationId || + !this.store.commandsReady() || + !isPlatformBrowser(this.platformId) + ) + return; + untracked(() => this.editorVisible.set(true)); + }); + inject(Events) + .on(serviceRequestStoreEvents.saved) + .pipe(takeUntilDestroyed()) + .subscribe(({ payload }) => { + if (payload.organizationId !== this.organizationId()) return; + this.editorVisible.set(false); + this.confirmedTitle.set(payload.request.title); + if (this.canRead()) + void this.router.navigate([ + '/organizations', + payload.organizationId, + 'service-requests', + payload.request.id, + ]); + }); + } + //#endregion + + //#region Methods + /** + * Method canLeaveDraft + * + * @description + * Protects accepted writes and entered creation drafts while the session remains established. + * Journal restoration does not trap a reader or prevent authentication redirects. + * + * @access public + * @since unreleased + * + * @returns {boolean | Promise} Whether the route may leave its current editor. + */ + public canLeaveDraft(): boolean | Promise { + if (!this.isAuthenticated()) return true; + if (this.store.writeCallState().status === 'pending') return false; + if (!this.editorVisible()) return true; + return this.editorSheet()?.canClose() ?? true; + } + + /** + * Method searchChanged + * + * @description + * Commits directory search and returns to the first server page. + * + * @access protected + * @since unreleased + * + * @param {Event} event - Native submit or input event. + * + * @returns {void} Result owned by the request workflow. + */ + protected searchChanged(event: Event): void { + if (event.target instanceof HTMLInputElement) { + this.page.set(1); + this.search.set(event.target.value); + } + } + /** + * Method statusChanged + * + * @description + * Accepts canonical server workflow filters. + * + * @access protected + * @since unreleased + * + * @param {unknown} value - Native choice value validated against the current options. + * + * @returns {void} Result owned by the request workflow. + */ + protected statusChanged(value: unknown): void { + if ( + value === '' || + value === 'requested' || + value === 'qualified' || + value === 'rejected' || + value === 'cancelled' || + value === 'converted' + ) { + this.page.set(1); + this.status.set(value); + } + } + /** + * Method open + * + * @description + * Captures the displayed revision and opens an explicitly permitted action. + * + * @access protected + * @since unreleased + * + * @returns {void} Result owned by the request workflow. + */ + protected open(): void { + if ( + !this.canCreate() || + !this.store.commandsReady() || + this.store.writeCallState().status === 'pending' + ) + return; + this.store.clearWrite(); + this.editorVisible.set(true); + } + /** + * Method create + * + * @description + * Submits a described target without implicitly qualifying or converting it. + * + * @access protected + * @since unreleased + * + * @param {CreateServiceRequestInput} data - Validated description fields. + * + * @returns {void} Result owned by the request workflow. + */ + protected create(data: CreateServiceRequestInput): void { + if ( + !this.canCreate() || + !this.online() || + !this.store.commandsReady() || + this.store.writeCallState().status === 'pending' + ) + return; + this.store.write({ kind: 'create', organizationId: this.organizationId(), input: data }); + } + /** + * Method retry + * + * @description + * Retries the currently permitted scoped resource read. + * + * @access protected + * @since unreleased + * + * @returns {void} Result owned by the request workflow. + */ + protected retry(): void { + const query = this.store.query(); + if (query && this.canRead() && this.online()) this.store.load(query); + } + //#endregion +} diff --git a/src/app/features/organization/features/service-requests/ui/pages/service-requests-page/testing/service-requests-page.component.spec.ts b/src/app/features/organization/features/service-requests/ui/pages/service-requests-page/testing/service-requests-page.component.spec.ts new file mode 100644 index 000000000..7ff83c2b4 --- /dev/null +++ b/src/app/features/organization/features/service-requests/ui/pages/service-requests-page/testing/service-requests-page.component.spec.ts @@ -0,0 +1,642 @@ +import { Location } from '@angular/common'; +import { provideLocationMocks } from '@angular/common/testing'; +import { + ChangeDetectionStrategy, + Component, + PLATFORM_ID, + computed, + getDebugNode, + signal, + type DebugElement, +} from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { + NavigationCancel, + NavigationEnd, + provideRouter, + Router, + withComponentInputBinding, +} from '@angular/router'; +import { RouterTestingHarness } from '@angular/router/testing'; +import { Dispatcher } from '@ngrx/signals/events'; +import { of, Subject, type Observable } from 'rxjs'; +import type { HydraCollection } from '@core/api/models'; +import { ConnectivityService } from '@core/connectivity'; +import { FeedbackService } from '@core/feedback'; +import { INTERACTION_CAPABILITIES_PORT } from '@core/interaction-capabilities'; +import { idleCallState } from '@core/request-state'; +import { THEME_PORT } from '@core/theme'; +import { OrganizationPermissionService } from '@features/organization/access'; +import { + EquipmentService, + EquipmentTypeService, +} from '@features/organization/features/equipments/data-access'; +import { FacilityService } from '@features/organization/features/facilities/data-access'; +import type { FacilityOutput } from '@features/organization/features/facilities/models'; +import { FacilityOptionPicker } from '@features/organization/features/facilities/ui/components'; +import { ServiceRequestService } from '@features/organization/features/service-requests/data-access'; +import type { ServiceRequestOutput } from '@features/organization/features/service-requests/models'; +import { serviceRequestFixture } from '@features/organization/features/service-requests/models/service-request/testing/service-request.fixture'; +import { SERVICE_REQUEST_ROUTES } from '@features/organization/features/service-requests/service-requests.routes'; +import { + ServiceRequestStore, + serviceRequestStoreEvents, +} from '@features/organization/features/service-requests/state'; +import { serviceRequestContext } from '@features/organization/features/service-requests/state/service-request/testing/service-request-context.fixture'; +import { ServiceRequestEquipmentPicker } from '@features/organization/features/service-requests/ui/components'; +import { ORGANIZATION_PERMISSION } from '@features/organization/models'; +import { REGIONAL_FORMATTING_PORT } from '@features/organization/ports'; +import { OrganizationMemberAccessStore } from '@features/organization/state'; +import { CollectionPagination } from '@shared/collection-pagination'; +import { DEFAULT_REGIONAL_FORMAT_SETTINGS } from '@shared/regional-format'; +import { ServiceRequestForm } from '../../../forms/service-request-form/service-request-form.component'; +import { ServiceRequestEditorSheet } from '../../../sheets/service-request-editor-sheet/service-request-editor-sheet.component'; +import { ServiceRequestsPage } from '../service-requests-page.component'; + +@Component({ template: 'Previous page', changeDetection: ChangeDetectionStrategy.OnPush }) +class PreviousPage {} + +describe('ServiceRequestsPage', () => { + const read = ORGANIZATION_PERMISSION.SERVICE_REQUESTS_READ; + const create = ORGANIZATION_PERMISSION.SERVICE_REQUESTS_CREATE; + + beforeAll(() => { + globalThis.ResizeObserver ??= class { + observe(): void {} + unobserve(): void {} + disconnect(): void {} + } as unknown as typeof ResizeObserver; + }); + + /** + * Function render + * + * @description + * Provides real query/write stores while observing the page's editor bindings separately. + */ + async function render( + options: { + readonly platform?: string; + readonly permissions?: readonly string[]; + readonly response?: Observable>; + readonly equipmentId?: string; + readonly siteId?: string; + readonly mobile?: boolean; + readonly routed?: boolean; + readonly realEditor?: boolean; + } = {}, + ) { + const permissions = signal>(new Set(options.permissions ?? [read, create])); + const context = serviceRequestContext(computed(() => [...permissions()])); + if (options.platform === 'server') context.journal.browser = false; + const mobile = signal(options.mobile ?? false); + const request = serviceRequestFixture(); + const api = { + list: vi.fn().mockReturnValue(options.response ?? of({ member: [request], totalItems: 61 })), + get: vi.fn(), + create: vi.fn().mockReturnValue(of(request)), + update: vi.fn(), + qualify: vi.fn(), + reject: vi.fn(), + cancel: vi.fn(), + convert: vi.fn(), + }; + const equipment = { + list: vi.fn().mockReturnValue(of({ member: [], totalItems: 0 })), + listByFacility: vi.fn().mockReturnValue(of({ member: [], totalItems: 0 })), + get: vi.fn(), + openWork: vi.fn(), + }; + const facilities = { + list: vi.fn().mockReturnValue(of({ member: [], totalItems: 0 })), + get: vi.fn(), + }; + const types = { list: vi.fn(), listAll: vi.fn().mockReturnValue(of([])) }; + TestBed.configureTestingModule({ + imports: [ServiceRequestsPage], + providers: [ + provideRouter( + options.routed + ? [ + { path: 'before', component: PreviousPage }, + { + path: 'organizations/:organizationId/service-requests', + children: SERVICE_REQUEST_ROUTES, + }, + ] + : [], + withComponentInputBinding(), + ), + provideLocationMocks(), + ...context.providers, + { provide: PLATFORM_ID, useValue: options.platform ?? 'browser' }, + { provide: ServiceRequestService, useValue: api }, + { provide: EquipmentService, useValue: equipment }, + { provide: EquipmentTypeService, useValue: types }, + { provide: FacilityService, useValue: facilities }, + { provide: THEME_PORT, useValue: { resolvedTheme: signal('light') } }, + { provide: FeedbackService, useValue: { warn: vi.fn() } }, + { + provide: OrganizationMemberAccessStore, + useValue: { ensureAccessResolved: () => of(undefined) }, + }, + { + provide: OrganizationPermissionService, + useValue: { + hasPermission: (permission: string) => permissions().has(permission), + canAccessOrganization: () => true, + }, + }, + { provide: ConnectivityService, useValue: { isOnline: signal(true) } }, + { + provide: REGIONAL_FORMATTING_PORT, + useValue: { regionalFormatting: signal(DEFAULT_REGIONAL_FORMAT_SETTINGS) }, + }, + { + provide: INTERACTION_CAPABILITIES_PORT, + useValue: { + isMobileInteractionMode: mobile, + interactionMode: signal('desktop'), + shortcutModifier: signal('Control'), + }, + }, + ], + }); + if (!options.realEditor) + TestBed.overrideComponent(ServiceRequestEditorSheet, { set: { template: '', imports: [] } }); + const harness = options.routed ? await RouterTestingHarness.create('/before') : null; + if (harness) TestBed.inject(Router).setUpLocationChangeListener(); + const routedPage = harness + ? await harness.navigateByUrl( + '/organizations/org/service-requests?create=1', + ServiceRequestsPage, + ) + : null; + const fixture = harness?.fixture ?? TestBed.createComponent(ServiceRequestsPage); + if (!harness) { + fixture.componentRef.setInput('organizationId', 'org'); + fixture.componentRef.setInput('equipmentId', options.equipmentId ?? ''); + fixture.componentRef.setInput('siteId', options.siteId ?? ''); + } + const navigate = vi.spyOn(TestBed.inject(Router), 'navigate'); + if (!options.routed) navigate.mockResolvedValue(true); + await fixture.whenStable(); + const element = (harness?.routeNativeElement ?? fixture.nativeElement) as HTMLElement; + return { + fixture, + element, + get sheet(): ServiceRequestEditorSheet { + return (harness?.routeDebugElement ?? fixture.debugElement).query( + By.directive(ServiceRequestEditorSheet), + ).componentInstance as ServiceRequestEditorSheet; + }, + api, + equipment, + facilities, + types, + permissions, + mobile, + navigate, + context, + harness, + page: routedPage, + }; + } + + it('keeps secondary collections and target selectors browser-only even with permissions during SSR', async () => { + const page = await render({ platform: 'server' }); + expect(page.api.list).not.toHaveBeenCalled(); + expect(page.equipment.list).not.toHaveBeenCalled(); + expect(page.facilities.list).not.toHaveBeenCalled(); + expect(page.types.listAll).not.toHaveBeenCalled(); + expect(page.sheet.visible()).toBe(false); + }); + + it('lets a creator submit without reading the collection or navigating to an unauthorized detail', async () => { + const page = await render({ permissions: [create] }); + expect(page.api.list).not.toHaveBeenCalled(); + const open = page.element.querySelector( + '[data-testid="service-request-new"]', + ); + expect(open).not.toBeNull(); + open?.click(); + await page.fixture.whenStable(); + expect(page.sheet.visible()).toBe(true); + const input = { + title: 'Repair damaged gauge', + description: 'The pressure gauge is damaged.', + equipmentId: 'equipment', + }; + page.sheet.descriptionSubmitted.emit(input); + await page.fixture.whenStable(); + expect(page.api.create).toHaveBeenCalledWith('org', input); + expect(page.sheet.visible()).toBe(false); + expect(page.element.textContent).toContain( + 'Maintenance request submitted: Repair damaged gauge', + ); + expect(page.navigate).not.toHaveBeenCalled(); + }); + + it('does not grant creation to managers or accept unauthorized editor output', async () => { + const page = await render({ + permissions: [read, ORGANIZATION_PERMISSION.SERVICE_REQUESTS_MANAGE], + }); + expect(page.element.querySelector('[data-testid="service-request-new"]')).toBeNull(); + page.sheet.descriptionSubmitted.emit({ + title: 'Forged editor intent', + description: 'Should not be sent.', + equipmentId: 'equipment', + }); + await page.fixture.whenStable(); + expect(page.api.create).not.toHaveBeenCalled(); + }); + + it('forwards route target, status, search and page while resetting pagination for changed filters', async () => { + const page = await render({ equipmentId: 'equipment', siteId: 'site' }); + expect(page.api.list).toHaveBeenLastCalledWith('org', { + page: 1, + itemsPerPage: 30, + search: '', + params: { equipmentId: 'equipment', siteId: 'site' }, + }); + const pagination = page.fixture.debugElement.query(By.directive(CollectionPagination)) + .componentInstance as CollectionPagination; + pagination.pageChanged.emit(2); + await page.fixture.whenStable(); + expect(page.api.list).toHaveBeenLastCalledWith('org', expect.objectContaining({ page: 2 })); + const input = page.element.querySelector('input[type="search"]'); + expect(input).not.toBeNull(); + if (!input) throw new Error('Search input is missing.'); + input.value = 'broken'; + input.dispatchEvent(new Event('input', { bubbles: true })); + await page.fixture.whenStable(); + expect(page.api.list).toHaveBeenLastCalledWith('org', { + page: 1, + itemsPerPage: 30, + search: 'broken', + params: { equipmentId: 'equipment', siteId: 'site' }, + }); + const qualified = [...page.element.querySelectorAll('button')].find( + (button) => button.textContent?.trim() === 'Qualified', + ); + expect(qualified).toBeDefined(); + qualified?.click(); + await page.fixture.whenStable(); + expect(page.api.list).toHaveBeenLastCalledWith('org', { + page: 1, + itemsPerPage: 30, + search: 'broken', + params: { equipmentId: 'equipment', siteId: 'site', status: 'qualified' }, + }); + }); + + it('ignores a saved event belonging to another organization', async () => { + const page = await render(); + TestBed.inject(Dispatcher).dispatch( + serviceRequestStoreEvents.saved({ + organizationId: 'foreign', + request: serviceRequestFixture({ organizationId: 'foreign', title: 'Foreign result' }), + kind: 'create', + }), + ); + await page.fixture.whenStable(); + expect(page.navigate).not.toHaveBeenCalled(); + expect(page.element.textContent).not.toContain('Foreign result'); + }); + + it('cancels previous organization reads when route scope changes', async () => { + const old = new Subject>(); + const page = await render({ response: old, equipmentId: 'old-equipment' }); + const current = serviceRequestFixture({ + organizationId: 'other', + id: 'other-request', + title: 'Current organization request', + }); + page.api.list.mockReturnValue(of({ member: [current], totalItems: 1 })); + const profile = page.context.profile(); + if (!profile) throw new Error('The current member fixture is missing.'); + page.context.profile.set({ ...profile, organizationId: 'other' }); + page.fixture.componentRef.setInput('organizationId', 'other'); + page.fixture.componentRef.setInput('equipmentId', 'other-equipment'); + await page.fixture.whenStable(); + expect(old.observed).toBe(false); + old.next({ + '@id': 'old', + '@type': 'Collection', + member: [serviceRequestFixture({ title: 'Obsolete organization result' })], + totalItems: 1, + }); + await page.fixture.whenStable(); + expect(page.api.list).toHaveBeenLastCalledWith( + 'other', + expect.objectContaining({ params: { equipmentId: 'other-equipment' } }), + ); + expect(page.element.textContent).toContain('Current organization request'); + expect(page.element.textContent).not.toContain('Obsolete organization result'); + expect(page.sheet.visible()).toBe(false); + }); + + it('uses the mobile interaction mode for request cards without another server query', async () => { + const page = await render(); + expect(page.element.querySelector('[data-testid="service-request-table"]')).not.toBeNull(); + const calls = page.api.list.mock.calls.length; + page.mobile.set(true); + await page.fixture.whenStable(); + expect(page.element.querySelector('[data-testid="service-request-cards"]')).not.toBeNull(); + expect(page.element.querySelector('[data-testid="service-request-table"]')).toBeNull(); + expect(page.element.textContent).toContain('Repair damaged gauge'); + expect(page.api.list).toHaveBeenCalledTimes(calls); + }); + + it.each(['actor', 'session'] as const)( + 'destroys the old typed editor when the same organization replaces its %s with equal permissions', + async (replacement) => { + const rendered = await render({ routed: true, realEditor: true }); + const oldSheet = rendered.sheet; + const oldForm = document.querySelector('app-service-request-form'); + const oldTitle = oldForm?.querySelector('#service-request-title'); + if (!oldForm || !oldTitle) throw new Error('The prior actor form is missing.'); + oldTitle.value = 'Private draft from the previous session'; + oldTitle.dispatchEvent(new Event('input', { bubbles: true })); + await rendered.fixture.whenStable(); + expect(oldSheet.hasDirty()).toBe(true); + const grants = [...rendered.permissions()]; + if (replacement === 'actor') { + const profile = rendered.context.profile(); + if (!profile) throw new Error('The member identity fixture is missing.'); + rendered.context.profile.set({ ...profile, userId: 'new-actor' }); + } else rendered.context.revision.update((revision) => revision + 1); + await rendered.fixture.whenStable(); + expect([...rendered.permissions()]).toEqual(grants); + expect(rendered.harness?.routeDebugElement?.componentInstance).toBe(rendered.page); + expect(document.contains(oldForm)).toBe(false); + expect(rendered.sheet).not.toBe(oldSheet); + expect(document.querySelector('#service-request-title')?.value).toBe(''); + expect(rendered.sheet.hasDirty()).toBe(false); + expect(rendered.api.create).not.toHaveBeenCalled(); + }, + ); + + it('keeps a typed draft on browser Back until discard, then destroys the page and returns with a clean editor', async () => { + const rendered = await render({ routed: true, realEditor: true }); + const harness = rendered.harness; + const page = rendered.page; + if (!harness || !page) throw new Error('The routed request page is missing.'); + const editor = document.querySelector('app-service-request-form'); + const title = editor?.querySelector('#service-request-title'); + if (!title) throw new Error('The routed request title is missing.'); + title.value = 'Keep this maintenance draft'; + title.dispatchEvent(new Event('input', { bubbles: true })); + await harness.fixture.whenStable(); + expect(rendered.sheet.hasDirty()).toBe(true); + const router = TestBed.inject(Router); + const terminal: Promise = new Promise((resolve) => { + const subscription = router.events.subscribe((event) => { + if (event instanceof NavigationEnd || event instanceof NavigationCancel) { + subscription.unsubscribe(); + resolve(event); + } + }); + }); + TestBed.inject(Location).back(); + await vi.waitFor(() => { + harness.detectChanges(); + expect(rendered.sheet['confirmation']()).toBe('open'); + expect(document.querySelector('[data-testid="unsaved-changes-dialog"]')).not.toBeNull(); + }); + expect(harness.routeDebugElement?.componentInstance).toBe(page); + const cancel = [ + ...document.querySelectorAll( + '[data-testid="unsaved-changes-dialog"] button', + ), + ].find((button) => button.textContent?.trim() === 'Cancel'); + if (!cancel) throw new Error('The draft confirmation cancel action is missing.'); + cancel.click(); + expect(await terminal).toBeInstanceOf(NavigationCancel); + await harness.fixture.whenStable(); + expect(router.url).toBe('/organizations/org/service-requests?create=1'); + expect(harness.routeDebugElement?.componentInstance).toBe(page); + expect(title.value).toBe('Keep this maintenance draft'); + const leaving = harness.navigateByUrl('/before', PreviousPage); + await vi.waitFor(() => + expect(document.querySelector('[data-testid="unsaved-changes-discard"]')).not.toBeNull(), + ); + ( + document.querySelector('[data-testid="unsaved-changes-discard"]') as HTMLButtonElement + ).click(); + await leaving; + expect(harness.routeDebugElement?.componentInstance).toBeInstanceOf(PreviousPage); + expect(document.querySelector('app-service-request-form')).toBeNull(); + const returned = await harness.navigateByUrl( + '/organizations/org/service-requests?create=1', + ServiceRequestsPage, + ); + await harness.fixture.whenStable(); + expect(returned).not.toBe(page); + const restoredTitle = document.querySelector('#service-request-title'); + expect(restoredTitle?.value).toBe(''); + expect(rendered.api.create).not.toHaveBeenCalled(); + }); + + it('allows confirmed creation navigation before the closed sheet input has rendered', async () => { + const rendered = await render(); + const page = rendered.fixture.componentInstance as ServiceRequestsPage; + ( + rendered.element.querySelector('[data-testid="service-request-new"]') as HTMLButtonElement + ).click(); + await rendered.fixture.whenStable(); + rendered.sheet['dirty'].set(true); + rendered.navigate.mockImplementationOnce(() => { + expect(rendered.sheet.visible()).toBe(true); + expect(rendered.sheet.hasDirty()).toBe(true); + expect(page.canLeaveDraft()).toBe(true); + return Promise.resolve(true); + }); + rendered.sheet.descriptionSubmitted.emit({ + title: 'Repair', + description: 'Damaged gauge', + equipmentId: 'equipment', + }); + await rendered.fixture.whenStable(); + expect(rendered.navigate).toHaveBeenCalledExactlyOnceWith([ + '/organizations', + 'org', + 'service-requests', + 'request', + ]); + expect(rendered.sheet['confirmation']()).toBe('closed'); + }); + + it('blocks route leave while an accepted creation remains pending', async () => { + const rendered = await render(); + const pending = new Subject(); + rendered.api.create.mockReturnValue(pending); + ( + rendered.element.querySelector('[data-testid="service-request-new"]') as HTMLButtonElement + ).click(); + await rendered.fixture.whenStable(); + rendered.sheet.descriptionSubmitted.emit({ + title: 'Repair', + description: 'Damaged gauge', + equipmentId: 'equipment', + }); + await rendered.fixture.whenStable(); + expect((rendered.fixture.componentInstance as ServiceRequestsPage).canLeaveDraft()).toBe(false); + expect(pending.observed).toBe(true); + }); + + it('normalizes missing router-bound target inputs before opening the real editor and submitting a site-only request', async () => { + const site: FacilityOutput = { + '@id': '/api/organizations/org/facilities/site', + '@type': 'Facility', + id: 'site', + organizationId: 'org', + parentFacilityId: null, + hasChildren: false, + type: 'site', + name: 'Hospital', + code: null, + status: 'active', + address: null, + metadata: {}, + path: [], + equipmentCount: 0, + recordStatus: 'published', + createdAt: '2026-10-06T10:00:00Z', + updatedAt: '2026-10-06T10:00:00Z', + }; + const store = { + listCallState: signal(idleCallState()), + writeCallState: signal(idleCallState()), + requestEntities: signal([]), + query: signal(null), + pageCount: signal(1), + total: signal(0), + load: vi.fn(), + write: vi.fn(), + clearWrite: vi.fn(), + commandsReady: signal(true), + commandCallState: signal(idleCallState()), + restoreCommands: vi.fn(), + activateCommands: vi.fn(), + }; + TestBed.configureTestingModule({ + imports: [ServiceRequestsPage], + providers: [ + provideRouter( + [ + { + path: 'organizations/:organizationId/service-requests', + component: ServiceRequestsPage, + }, + ], + withComponentInputBinding(), + ), + ...serviceRequestContext().providers, + { provide: PLATFORM_ID, useValue: 'browser' }, + { + provide: EquipmentService, + useValue: { + list: vi.fn().mockReturnValue(of({ member: [], totalItems: 0 })), + listByFacility: vi.fn().mockReturnValue(of({ member: [], totalItems: 0 })), + get: vi.fn(), + }, + }, + { + provide: EquipmentTypeService, + useValue: { listAll: vi.fn().mockReturnValue(of([])) }, + }, + { + provide: FacilityService, + useValue: { + list: vi.fn().mockReturnValue(of({ member: [site], totalItems: 1 })), + get: vi.fn().mockReturnValue(of(site)), + }, + }, + { provide: THEME_PORT, useValue: { resolvedTheme: signal('light') } }, + { provide: OrganizationPermissionService, useValue: { hasPermission: () => true } }, + { provide: ConnectivityService, useValue: { isOnline: signal(true) } }, + { + provide: REGIONAL_FORMATTING_PORT, + useValue: { regionalFormatting: signal(DEFAULT_REGIONAL_FORMAT_SETTINGS) }, + }, + { + provide: INTERACTION_CAPABILITIES_PORT, + useValue: { + isMobileInteractionMode: signal(false), + interactionMode: signal('desktop'), + shortcutModifier: signal('Control'), + }, + }, + ], + }); + TestBed.overrideComponent(ServiceRequestsPage, { + remove: { providers: [ServiceRequestStore] }, + add: { providers: [{ provide: ServiceRequestStore, useValue: store }] }, + }); + const harness = await RouterTestingHarness.create(); + const page = await harness.navigateByUrl( + '/organizations/org/service-requests?create=1', + ServiceRequestsPage, + ); + await harness.fixture.whenStable(); + expect(page.organizationId()).toBe('org'); + expect(page.equipmentId()).toBeUndefined(); + expect(page.siteId()).toBeUndefined(); + const sheet = harness.routeDebugElement?.query(By.directive(ServiceRequestEditorSheet)) + .componentInstance as ServiceRequestEditorSheet; + expect(sheet.visible()).toBe(true); + expect(sheet.initialEquipmentId()).toBe(''); + expect(sheet.initialSiteId()).toBe(''); + const formElement = document.querySelector('app-service-request-form'); + expect(formElement).not.toBeNull(); + if (!formElement) throw new Error('The routed editor form is missing.'); + const form = (getDebugNode(formElement) as DebugElement) + .componentInstance as ServiceRequestForm; + expect(form.initialEquipmentId()).toBe(''); + expect(form.initialSiteId()).toBe(''); + const facilityElement = formElement.querySelector('app-facility-option-picker'); + const equipmentElement = formElement.querySelector( + 'app-service-request-equipment-picker', + ); + expect(facilityElement).not.toBeNull(); + expect(equipmentElement).not.toBeNull(); + if (!facilityElement || !equipmentElement) + throw new Error('A routed target picker is missing.'); + const facilityPicker = (getDebugNode(facilityElement) as DebugElement) + .componentInstance as FacilityOptionPicker; + const equipmentPicker = (getDebugNode(equipmentElement) as DebugElement) + .componentInstance as ServiceRequestEquipmentPicker; + expect(facilityPicker.value()).toBe(''); + expect(equipmentPicker.value()).toBe(''); + facilityPicker.value.set(site.id); + for (const [selector, value] of [ + ['#service-request-title', ' Repair '], + ['#service-request-description', ' Damaged gauge '], + ]) { + const input = formElement.querySelector(selector); + if (!input) throw new Error('A routed description field is missing.'); + input.value = value; + input.dispatchEvent(new Event('input', { bubbles: true })); + } + await harness.fixture.whenStable(); + formElement + .querySelector('form') + ?.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); + await harness.fixture.whenStable(); + expect(store.write).toHaveBeenCalledExactlyOnceWith({ + kind: 'create', + organizationId: 'org', + input: { + title: 'Repair', + description: 'Damaged gauge', + priority: 'normal', + equipmentId: null, + siteId: site.id, + originInspectionId: null, + originNonConformityId: null, + }, + }); + }); +}); diff --git a/src/app/features/organization/features/service-requests/ui/sheets/service-request-editor-sheet/service-request-editor-sheet.component.html b/src/app/features/organization/features/service-requests/ui/sheets/service-request-editor-sheet/service-request-editor-sheet.component.html new file mode 100644 index 000000000..1ce22b952 --- /dev/null +++ b/src/app/features/organization/features/service-requests/ui/sheets/service-request-editor-sheet/service-request-editor-sheet.component.html @@ -0,0 +1,130 @@ + + + +

{{ title() }}

+

+ Qualify the maintenance need before explicitly organizing corrective work. +

+
+ @if (conflict()) { +
+

+ The request has changed. Review its latest version before applying your draft. +

+ + @if (latestRequest(); as latest) { +

{{ latest.title }} · {{ statusLabel(latest.status) }}

+

{{ latest.description }}

+ @if (!canAcceptRevision()) { +

+ The current request state no longer allows this action. +

+ } + + } +
+ } + @if (uncertain()) { +
+

+ The work link has not been confirmed. Retry this conversion before choosing another + action. +

+ +
+ } + @if (kind() === 'create' || kind() === 'update') { + + } + @if (actionKind(); as actionKind) { + @if (request(); as request) { + + @if (workError()) { + + } + } + } +
+
+ diff --git a/src/app/features/organization/features/service-requests/ui/sheets/service-request-editor-sheet/service-request-editor-sheet.component.ts b/src/app/features/organization/features/service-requests/ui/sheets/service-request-editor-sheet/service-request-editor-sheet.component.ts new file mode 100644 index 000000000..366693c23 --- /dev/null +++ b/src/app/features/organization/features/service-requests/ui/sheets/service-request-editor-sheet/service-request-editor-sheet.component.ts @@ -0,0 +1,684 @@ +import { + ChangeDetectionStrategy, + Component, + DestroyRef, + computed, + input, + inject, + output, + signal, + viewChild, +} from '@angular/core'; +import type { BrnDialogState } from '@spartan-ng/brain/dialog'; +import type { StoreError } from '@core/request-state'; +import type { EquipmentOpenWorkOutput } from '@features/organization/features/equipments/models'; +import type { + ServiceRequestOutput, + CreateServiceRequestInput, +} from '@features/organization/features/service-requests/models'; +import { serviceRequestStatusLabel } from '@features/organization/features/service-requests/utils'; +import { sheetSide } from '@shared/sheet-side'; +import { HlmAlertImports } from '@shared/ui/alert'; +import { HlmButton } from '@shared/ui/button'; +import { HlmSheet, HlmSheetImports } from '@shared/ui/sheet'; +import { UnsavedChangesDialog } from '@shared/unsaved-changes'; +import { + ServiceRequestActionForm, + type ServiceRequestActionKind, + type ServiceRequestActionIntent, +} from '../../forms/service-request-action-form/service-request-action-form.component'; +import { ServiceRequestForm } from '../../forms/service-request-form/service-request-form.component'; +/** + * Type ServiceRequestEditorKind + * + * @description + * Creation, editable description and explicit workflow surfaces use one overlay policy. + * + * @since unreleased + * + * @type {ServiceRequestEditorKind} + */ +export type ServiceRequestEditorKind = 'create' | 'update' | ServiceRequestActionKind; +/** + * Class ServiceRequestEditorSheet + * @class ServiceRequestEditorSheet + * + * @description + * Native workflow sheet preserves dirty drafts, pending writes and uncertain conversion commands. + * + * @since unreleased + */ +@Component({ + selector: 'app-service-request-editor-sheet', + templateUrl: './service-request-editor-sheet.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + HlmButton, + ServiceRequestForm, + ServiceRequestActionForm, + UnsavedChangesDialog, + ...HlmSheetImports, + ...HlmAlertImports, + ], +}) +export class ServiceRequestEditorSheet { + //#region Properties + /** + * Property deactivationDecision + * + * @description + * Reuses one explicit discard decision while several route navigations are attempted. + * + * @access private + * @since unreleased + * + * @type {Promise | null} + */ + private deactivationDecision: Promise | null = null; + + /** + * Property deactivationResolver + * + * @description + * Completes the pending router decision when the native confirmation is answered. + * + * @access private + * @since unreleased + * + * @type {((allowed: boolean) => void) | null} + */ + private deactivationResolver: ((allowed: boolean) => void) | null = null; + + /** + * Property organizationId + * @readonly + * + * @description + * Organization authority shared by the owning route and server transport. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly organizationId = input.required(); + /** + * Property visible + * @readonly + * + * @description + * Controlled overlay visibility provided by the owning page. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly visible = input(false); + /** + * Property kind + * @readonly + * + * @description + * Explicit workflow action currently being prepared. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly kind = input('create'); + /** + * Property request + * @readonly + * + * @description + * Immutable request revision supplied by the owning page. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly request = input(null); + /** + * Property initialEquipmentId + * @readonly + * + * @description + * Optional equipment prefill supplied by a dossier entry link. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly initialEquipmentId = input(''); + /** + * Property initialSiteId + * @readonly + * + * @description + * Optional root-site prefill supplied by a dossier entry link. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly initialSiteId = input(''); + /** + * Property originInspectionId + * @readonly + * + * @description + * Optional source inspection preserved in the creation input. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly originInspectionId = input(''); + /** + * Property originNonConformityId + * @readonly + * + * @description + * Optional source anomaly preserved in the creation input. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly originNonConformityId = input(''); + /** + * Property pending + * @readonly + * + * @description + * Disables editing while the owning mutation is accepted. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly pending = input(false); + /** + * Property uncertain + * @readonly + * + * @description + * An unconfirmed conversion retains its exact command before another choice. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly uncertain = input(false); + /** + * Property canRetryConversion + * @readonly + * + * @description + * Management and connectivity allow replay; the server confirms receipts before new-work + * planning. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly canRetryConversion = input(false); + /** + * Property conflict + * @readonly + * + * @description + * A stale revision requires an explicit user review. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly conflict = input(false); + /** + * Property latestRequest + * @readonly + * + * @description + * Reviewed server projection shown separately from the captured revision. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly latestRequest = input(null); + /** + * Property canAcceptRevision + * @readonly + * + * @description + * The current state must still allow adopting the reviewed revision. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly canAcceptRevision = input(false); + /** + * Property error + * @readonly + * + * @description + * Normalized command error displayed without resetting the draft. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly error = input(null); + /** + * Property openWork + * @readonly + * + * @description + * Authorized current repair tasks supplied by the owning page. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly openWork = input([]); + /** + * Property workPending + * @readonly + * + * @description + * A pending open-work read prevents uncertain conversion choices. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly workPending = input(false); + /** + * Property workError + * @readonly + * + * @description + * An unresolved open-work failure remains visible and blocks conversion. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly workError = input(null); + /** + * Property workReadable + * @readonly + * + * @description + * Actual permission to view existing work, distinct from an empty work list. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly workReadable = input(true); + /** + * Property descriptionSubmitted + * @readonly + * + * @description + * Validated description intent relayed to the owning page. + * + * @access public + * @since unreleased + * + * @type {OutputEmitterRef} + */ + public readonly descriptionSubmitted = output(); + /** + * Property actionSubmitted + * @readonly + * + * @description + * Validated workflow intent relayed to the owning page. + * + * @access public + * @since unreleased + * + * @type {OutputEmitterRef} + */ + public readonly actionSubmitted = output(); + /** + * Property dismissed + * @readonly + * + * @description + * Confirmed overlay dismissal without an accepted mutation in flight. + * + * @access public + * @since unreleased + * + * @type {OutputEmitterRef} + */ + public readonly dismissed = output(); + /** + * Property refreshRequested + * @readonly + * + * @description + * Explicit request to read the current server revision. + * + * @access public + * @since unreleased + * + * @type {OutputEmitterRef} + */ + public readonly refreshRequested = output(); + /** + * Property revisionAccepted + * @readonly + * + * @description + * Explicit acceptance of the reviewed revision while retaining the entered draft. + * + * @access public + * @since unreleased + * + * @type {OutputEmitterRef} + */ + public readonly revisionAccepted = output(); + /** + * Property retryConversionRequested + * @readonly + * + * @description + * Replays the retained conversion instead of creating another command. + * + * @access public + * @since unreleased + * + * @type {OutputEmitterRef} + */ + public readonly retryConversionRequested = output(); + /** + * Property retryWorkRequested + * @readonly + * + * @description + * Retries an unresolved authorized open-work read. + * + * @access public + * @since unreleased + * + * @type {OutputEmitterRef} + */ + public readonly retryWorkRequested = output(); + /** + * Property sheetRef + * @readonly + * + * @description + * Native sheet reference used to preserve a dirty draft after native dismissal. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly sheetRef = viewChild(HlmSheet); + /** + * Property dirty + * @readonly + * + * @description + * Signal Forms dirtiness reported by the active form. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal} + */ + protected readonly dirty = signal(false); + /** + * Property confirmation + * @readonly + * + * @description + * Native unsaved-change decision state. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal} + */ + protected readonly confirmation = signal('closed'); + /** + * Property state + * @readonly + * + * @description + * Native overlay state derived from its controlled visibility. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly state = computed(() => (this.visible() ? 'open' : 'closed')); + /** + * Property side + * @readonly + * + * @description + * Project sheet placement follows the central interaction mode. + * + * @access protected + * @since unreleased + * + * @type {Signal<'right' | 'bottom'>} + */ + protected readonly side = sheetSide(); + /** + * Property actionKind + * @readonly + * + * @description + * Workflow-only action selected separately from creation and description editing. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly actionKind = computed(() => { + const kind = this.kind(); + return kind === 'create' || kind === 'update' ? null : kind; + }); + /** + * Property statusLabel + * @readonly + * + * @description + * Localized workflow label from the owning request vocabulary. + * + * @access protected + * @since unreleased + * + * @type {(status: ServiceRequestStatus) => string} + */ + protected readonly statusLabel = serviceRequestStatusLabel; + /** + * Property title + * @readonly + * + * @description + * Declared request title or localized overlay title. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly title = computed(() => { + switch (this.kind()) { + case 'create': + return $localize`:@@serviceRequest.editor.create:New maintenance request`; + case 'update': + return $localize`:@@serviceRequest.editor.edit:Edit maintenance request`; + case 'qualify': + return $localize`:@@serviceRequest.editor.qualify:Qualify maintenance request`; + case 'reject': + return $localize`:@@serviceRequest.action.reject:Reject request`; + case 'cancel': + return $localize`:@@serviceRequest.action.cancel:Cancel request`; + case 'convert': + return $localize`:@@serviceRequest.editor.convert:Organize corrective work`; + } + }); + //#endregion + + //#region Constructor + /** + * Constructor + * @constructor + * + * @description + * Releases a pending discard decision when the sheet owner is destroyed. + * + * @access public + * @since unreleased + */ + public constructor() { + inject(DestroyRef).onDestroy(() => this.resolveConfirmation(false)); + } + //#endregion + + //#region Methods + /** + * Method hasDirty + * + * @description + * Exposes the active Signal Forms draft to its route-owning page. + * + * @access public + * @since unreleased + * + * @returns {boolean} Whether leaving the visible editor would discard entered changes. + */ + public hasDirty(): boolean { + return this.visible() && this.dirty(); + } + + /** + * Method canClose + * + * @description + * Uses the same native discard confirmation for sheet dismissal and guarded navigation. + * + * @access public + * @since unreleased + * + * @returns {boolean | Promise} Explicit discard decision or the immediate close policy. + */ + public canClose(): boolean | Promise { + if (this.pending() || this.uncertain()) return false; + if (!this.hasDirty()) return true; + if (this.deactivationDecision) return this.deactivationDecision; + this.confirmation.set('open'); + this.deactivationDecision = new Promise((resolve) => { + this.deactivationResolver = resolve; + }); + return this.deactivationDecision; + } + + /** + * Method resolveConfirmation + * + * @description + * Completes one navigation decision without changing the typed draft on cancellation. + * + * @access protected + * @since unreleased + * + * @param {boolean} allowed - Whether the reader explicitly chose to discard entered changes. + * + * @returns {void} Resolves and releases the current confirmation. + */ + protected resolveConfirmation(allowed: boolean): void { + this.confirmation.set('closed'); + this.deactivationResolver?.(allowed); + this.deactivationResolver = null; + this.deactivationDecision = null; + } + + /** + * Method requestClose + * + * @description + * Retains pending and uncertain commands and asks before discarding a dirty draft. + * + * @access protected + * @since unreleased + * + * @returns {void} Result owned by the request workflow. + */ + protected requestClose(): void { + if (this.pending() || this.uncertain()) return; + if (this.dirty()) this.confirmation.set('open'); + else this.dismissed.emit(); + } + /** + * Method stateChanged + * + * @description + * Routes native dismissal through the same pending, uncertainty and dirtiness policy. + * + * @access protected + * @since unreleased + * + * @param {BrnDialogState} state - Native overlay visibility. + * + * @returns {void} Result owned by the request workflow. + */ + protected stateChanged(state: BrnDialogState): void { + if (state !== 'closed' || !this.visible()) return; + if (this.dirty() || this.pending() || this.uncertain()) this.sheetRef()?.open(); + this.requestClose(); + } + /** + * Method discard + * + * @description + * Discards the draft only after the explicit unsaved-change confirmation. + * + * @access protected + * @since unreleased + * + * @returns {void} Result owned by the request workflow. + */ + protected discard(): void { + if (this.pending() || this.uncertain()) { + this.resolveConfirmation(false); + return; + } + this.dirty.set(false); + this.dismissed.emit(); + this.resolveConfirmation(true); + } + //#endregion +} diff --git a/src/app/features/organization/features/service-requests/ui/sheets/service-request-editor-sheet/testing/service-request-editor-sheet.component.spec.ts b/src/app/features/organization/features/service-requests/ui/sheets/service-request-editor-sheet/testing/service-request-editor-sheet.component.spec.ts new file mode 100644 index 000000000..321dc9468 --- /dev/null +++ b/src/app/features/organization/features/service-requests/ui/sheets/service-request-editor-sheet/testing/service-request-editor-sheet.component.spec.ts @@ -0,0 +1,67 @@ +import { signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { INTERACTION_CAPABILITIES_PORT } from '@core/interaction-capabilities'; +import { ServiceRequestEditorSheet } from '../service-request-editor-sheet.component'; + +async function render() { + TestBed.configureTestingModule({ + imports: [ServiceRequestEditorSheet], + providers: [ + { + provide: INTERACTION_CAPABILITIES_PORT, + useValue: { isMobileInteractionMode: signal(false) }, + }, + ], + }); + TestBed.overrideComponent(ServiceRequestEditorSheet, { set: { template: '', imports: [] } }); + const fixture = TestBed.createComponent(ServiceRequestEditorSheet); + fixture.componentRef.setInput('organizationId', 'org'); + fixture.componentRef.setInput('visible', true); + await fixture.whenStable(); + return { fixture, sheet: fixture.componentInstance }; +} + +describe('ServiceRequestEditorSheet', () => { + it('keeps a dirty draft when navigation confirmation is cancelled and resolves the next discard once', async () => { + const { fixture, sheet } = await render(); + const dismissed = vi.fn(); + sheet.dismissed.subscribe(dismissed); + sheet['dirty'].set(true); + expect(sheet.hasDirty()).toBe(true); + const first = sheet.canClose(); + expect(sheet['confirmation']()).toBe('open'); + expect(sheet.canClose()).toBe(first); + sheet['resolveConfirmation'](false); + expect(await first).toBe(false); + expect(sheet.hasDirty()).toBe(true); + expect(dismissed).not.toHaveBeenCalled(); + const second = sheet.canClose(); + sheet['discard'](); + expect(await second).toBe(true); + expect(sheet.hasDirty()).toBe(false); + expect(dismissed).toHaveBeenCalledOnce(); + fixture.destroy(); + }); + + it('blocks native dismissal and route leave while an accepted write is pending', async () => { + const { fixture, sheet } = await render(); + const dismissed = vi.fn(); + sheet.dismissed.subscribe(dismissed); + sheet['dirty'].set(true); + fixture.componentRef.setInput('pending', true); + await fixture.whenStable(); + expect(sheet.canClose()).toBe(false); + sheet['requestClose'](); + sheet['discard'](); + expect(sheet.hasDirty()).toBe(true); + expect(dismissed).not.toHaveBeenCalled(); + }); + + it('releases an unanswered router decision when its owner is destroyed', async () => { + const { fixture, sheet } = await render(); + sheet['dirty'].set(true); + const decision = sheet.canClose(); + fixture.destroy(); + expect(await decision).toBe(false); + }); +}); diff --git a/src/app/features/organization/features/service-requests/utils/index.ts b/src/app/features/organization/features/service-requests/utils/index.ts new file mode 100644 index 000000000..9692c9bb2 --- /dev/null +++ b/src/app/features/organization/features/service-requests/utils/index.ts @@ -0,0 +1,4 @@ +export { + serviceRequestStatusLabel, + serviceRequestPriorityLabel, +} from './service-request-label/service-request-label.utils'; diff --git a/src/app/features/organization/features/service-requests/utils/service-request-label/service-request-label.utils.ts b/src/app/features/organization/features/service-requests/utils/service-request-label/service-request-label.utils.ts new file mode 100644 index 000000000..000e4757e --- /dev/null +++ b/src/app/features/organization/features/service-requests/utils/service-request-label/service-request-label.utils.ts @@ -0,0 +1,56 @@ +import type { + ServiceRequestStatus, + ServiceRequestPriority, +} from '@features/organization/features/service-requests/models'; +/** + * Function serviceRequestStatusLabel + * + * @description + * Localizes canonical request workflow states without inferring completion from another module. + * + * @access public + * @since unreleased + * + * @param {ServiceRequestStatus} status - Canonical server workflow status. + * + * @returns {string} Localized human-readable request value. + */ +export function serviceRequestStatusLabel(status: ServiceRequestStatus): string { + switch (status) { + case 'requested': + return $localize`:@@serviceRequest.status.requested:Requested`; + case 'qualified': + return $localize`:@@serviceRequest.status.qualified:Qualified`; + case 'rejected': + return $localize`:@@serviceRequest.status.rejected:Rejected`; + case 'cancelled': + return $localize`:@@serviceRequest.status.cancelled:Cancelled`; + case 'converted': + return $localize`:@@serviceRequest.status.converted:Converted into work`; + } +} +/** + * Function serviceRequestPriorityLabel + * + * @description + * Localizes the declared request priority. + * + * @access public + * @since unreleased + * + * @param {ServiceRequestPriority} priority - Canonical declared priority. + * + * @returns {string} Localized human-readable request value. + */ +export function serviceRequestPriorityLabel(priority: ServiceRequestPriority): string { + switch (priority) { + case 'low': + return $localize`:@@serviceRequest.priority.low:Low`; + case 'normal': + return $localize`:@@serviceRequest.priority.normal:Normal`; + case 'high': + return $localize`:@@serviceRequest.priority.high:High`; + case 'urgent': + return $localize`:@@serviceRequest.priority.urgent:Urgent`; + } +} diff --git a/src/app/features/organization/http/guards/organization/organization.guard.ts b/src/app/features/organization/http/guards/organization/organization.guard.ts index 1a40cc05d..8c9beda89 100644 --- a/src/app/features/organization/http/guards/organization/organization.guard.ts +++ b/src/app/features/organization/http/guards/organization/organization.guard.ts @@ -7,67 +7,74 @@ import { Router, type UrlTree, } from '@angular/router'; -import { map, catchError, of, type Observable } from 'rxjs'; +import { map, switchMap, catchError, of, type Observable } from 'rxjs'; import type { HydraCollection } from '@core/api/models'; import { CookieService } from '@core/cookie'; import { LAST_ORGANIZATION_COOKIE_NAME } from '@features/organization/constants'; import { OrganizationService } from '@features/organization/data-access'; import type { OrganizationOutput } from '@features/organization/models'; +import { OrganizationLandingService } from '@features/organization/services/organization-landing'; /** - * Guard organizationGuard + * Function organizationGuard * * @description * Always redirects to the user's default organization workspace. The last * organization persisted in the `last-organization` cookie wins when it is * still accessible; otherwise the first accessible organization is used. * Users without any organization are sent directly to `/onboarding/workspace`. - * + * The selected organization's confirmed facilities-read grant opens its fleet; + * otherwise the historical dashboard remains its default destination. * An `excluded` query parameter names an organization that must not be picked * again (set by guards that just failed to resolve it), breaking redirect * loops between this guard and the organization access/landing guards. * * @version 1.0.0 + * * @author Valentin FORTIN * * @returns {MaybeAsync} A UrlTree redirecting to the appropriate route - * based on the user's organizations. + * based on the user's organizations. */ export const organizationGuard: CanActivateFn = ( route: ActivatedRouteSnapshot, ): MaybeAsync => { /** * Constant organizationService - * @const organizationService * * @description * Service for fetching organization data from the API to determine * the appropriate redirection path based on the user's organizations. * + * @const organizationService + * * @var {OrganizationService} */ const organizationService: OrganizationService = inject(OrganizationService); + const landing: OrganizationLandingService = inject(OrganizationLandingService); /** * Constant cookieService - * @const cookieService * * @description * Cookie service used to read (and invalidate) the persisted * last-organization preference. * + * @const cookieService + * * @var {CookieService} */ const cookieService: CookieService = inject(CookieService); /** * Constant router - * @const router * * @description * Router for creating redirection URL trees based on the presence * of user organizations. * + * @const router + * * @var {Router} */ const router: Router = inject(Router); @@ -91,22 +98,24 @@ export const organizationGuard: CanActivateFn = ( */ const fallbackToFirstAccessible = (): Observable => organizationService.list({ page: 1, itemsPerPage: 2 }).pipe( - map((response: HydraCollection): UrlTree => { + switchMap((response: HydraCollection): Observable => { const organization: OrganizationOutput | undefined = response.member.find( (candidate: OrganizationOutput): boolean => candidate.id !== excludedId, ); if (organization) { - return router.createUrlTree(['/organizations', organization.id]); + return landing + .defaultDestination(organization.id) + .pipe(map((destination) => router.parseUrl(destination))); } // Organizations exist but none is usable (all excluded): avoid looping if (response.totalItems > 0) { - return router.createUrlTree(['/error/403']); + return of(router.createUrlTree(['/error/403'])); } // A stale onboarding access cache must not send this account back here. - return router.createUrlTree(['/onboarding/workspace']); + return of(router.createUrlTree(['/onboarding/workspace'])); }), catchError(() => of(router.createUrlTree(['/error/500']))), ); @@ -118,8 +127,10 @@ export const organizationGuard: CanActivateFn = ( // Validate the saved organization is still accessible before redirecting to it return organizationService.get(savedId).pipe( - map((organization: OrganizationOutput): UrlTree => - router.createUrlTree(['/organizations', organization.id]), + switchMap((organization: OrganizationOutput) => + landing + .defaultDestination(organization.id) + .pipe(map((destination) => router.parseUrl(destination))), ), catchError(() => { // Stale preference (organization deleted or membership revoked): forget it diff --git a/src/app/features/organization/http/guards/organization/testing/organization.guard.spec.ts b/src/app/features/organization/http/guards/organization/testing/organization.guard.spec.ts index 06ccb3067..4ccc51ff3 100644 --- a/src/app/features/organization/http/guards/organization/testing/organization.guard.spec.ts +++ b/src/app/features/organization/http/guards/organization/testing/organization.guard.spec.ts @@ -6,6 +6,7 @@ import { CookieService } from '@core/cookie'; import { LAST_ORGANIZATION_COOKIE_NAME } from '@features/organization/constants'; import { OrganizationService } from '@features/organization/data-access'; import type { OrganizationOutput } from '@features/organization/models'; +import { OrganizationLandingService } from '@features/organization/services/organization-landing'; import { organizationGuard } from '../organization.guard'; function createCollection( @@ -51,6 +52,7 @@ describe('organizationGuard', () => { let mockRouter: { createUrlTree: ReturnType; + parseUrl: ReturnType; }; let mockOrganizationService: { get: ReturnType; @@ -64,6 +66,7 @@ describe('organizationGuard', () => { beforeEach(() => { mockRouter = { createUrlTree: vi.fn().mockReturnValue(redirectUrlTree), + parseUrl: vi.fn().mockReturnValue(redirectUrlTree), }; mockOrganizationService = { @@ -81,6 +84,14 @@ describe('organizationGuard', () => { { provide: Router, useValue: mockRouter }, { provide: OrganizationService, useValue: mockOrganizationService }, { provide: CookieService, useValue: mockCookieService }, + { + provide: OrganizationLandingService, + useValue: { + defaultDestination: vi.fn((organizationId: string) => + of(`/organizations/${organizationId}/assets`), + ), + }, + }, ], }); }); @@ -93,7 +104,7 @@ describe('organizationGuard', () => { expect(result).toBe(redirectUrlTree); expect(mockOrganizationService.get).not.toHaveBeenCalled(); expect(mockOrganizationService.list).toHaveBeenCalledWith({ page: 1, itemsPerPage: 2 }); - expect(mockRouter.createUrlTree).toHaveBeenCalledWith(['/organizations', 'org-1']); + expect(mockRouter.parseUrl).toHaveBeenCalledWith('/organizations/org-1/assets'); }); it('should redirect to onboarding when the user has no organization', async () => { @@ -119,7 +130,7 @@ describe('organizationGuard', () => { expect(mockCookieService.getCookie).toHaveBeenCalledWith(LAST_ORGANIZATION_COOKIE_NAME); expect(mockOrganizationService.get).toHaveBeenCalledWith('org-2'); expect(mockOrganizationService.list).not.toHaveBeenCalled(); - expect(mockRouter.createUrlTree).toHaveBeenCalledWith(['/organizations', 'org-2']); + expect(mockRouter.parseUrl).toHaveBeenCalledWith('/organizations/org-2/assets'); }); it('should forget a stale cookie and fall back to the first accessible organization', async () => { @@ -134,7 +145,7 @@ describe('organizationGuard', () => { expect(mockOrganizationService.get).toHaveBeenCalledWith('org-gone'); expect(mockCookieService.deleteCookie).toHaveBeenCalledWith(LAST_ORGANIZATION_COOKIE_NAME); expect(mockOrganizationService.list).toHaveBeenCalledWith({ page: 1, itemsPerPage: 2 }); - expect(mockRouter.createUrlTree).toHaveBeenCalledWith(['/organizations', 'org-1']); + expect(mockRouter.parseUrl).toHaveBeenCalledWith('/organizations/org-1/assets'); }); it('should skip the excluded saved organization and pick the next accessible one', async () => { @@ -147,7 +158,7 @@ describe('organizationGuard', () => { expect(result).toBe(redirectUrlTree); expect(mockOrganizationService.get).not.toHaveBeenCalled(); expect(mockOrganizationService.list).toHaveBeenCalledWith({ page: 1, itemsPerPage: 2 }); - expect(mockRouter.createUrlTree).toHaveBeenCalledWith(['/organizations', 'org-2']); + expect(mockRouter.parseUrl).toHaveBeenCalledWith('/organizations/org-2/assets'); }); it('should redirect to the forbidden page when every organization is excluded', async () => { diff --git a/src/app/features/organization/models/index.ts b/src/app/features/organization/models/index.ts index be8653cc8..813c0c168 100644 --- a/src/app/features/organization/models/index.ts +++ b/src/app/features/organization/models/index.ts @@ -1,4 +1,5 @@ export type { OrganizationOutput } from './organization-entity/organization-output.interface'; +export type { OrganizationOperatingProfile } from './organization-entity/organization-operating-profile.type'; export type { OrganizationRegisteredAddress } from './organization-entity/organization-registered-address.interface'; export type { OrganizationMembershipRoleOutput } from './organization-entity/organization-membership-role-output.interface'; export type { OrganizationTransferOwnershipConfirmedEvent } from './organization-transfer-ownership/organization-transfer-ownership-confirmed-event.interface'; @@ -159,3 +160,5 @@ export type { PresenceStatus, PresenceSubscriptionOutput, } from './presence'; + +export type { ParkAnomaliesSummaryOutput } from './park/park-anomalies-summary-output.interface'; diff --git a/src/app/features/organization/models/organization-entity/create-organization-input.interface.ts b/src/app/features/organization/models/organization-entity/create-organization-input.interface.ts index d0c6389a5..c9016897b 100644 --- a/src/app/features/organization/models/organization-entity/create-organization-input.interface.ts +++ b/src/app/features/organization/models/organization-entity/create-organization-input.interface.ts @@ -7,9 +7,26 @@ */ export interface CreateOrganizationInput { //#region Properties - /** @type {string} */ + /** + * Property name + * + * @type {string} + */ readonly name: string; - /** @type {string | null} */ + /** + * Property operatingProfile + * + * @description + * Operational defaults; omission preserves the legacy operator profile. + * + * @property operatingProfile + */ + readonly operatingProfile?: 'operator' | 'service_provider'; + /** + * Property slug + * + * @type {string | null} + */ readonly slug?: string | null; //#endregion } diff --git a/src/app/features/organization/models/organization-entity/organization-operating-profile.type.ts b/src/app/features/organization/models/organization-entity/organization-operating-profile.type.ts new file mode 100644 index 000000000..e75842f4d --- /dev/null +++ b/src/app/features/organization/models/organization-entity/organization-operating-profile.type.ts @@ -0,0 +1,9 @@ +/** + * Type OrganizationOperatingProfile + * + * @description + * Selects operational defaults independently from organization permissions. + * + * @type {'operator' | 'service_provider'} + */ +export type OrganizationOperatingProfile = 'operator' | 'service_provider'; diff --git a/src/app/features/organization/models/organization-entity/organization-output.interface.ts b/src/app/features/organization/models/organization-entity/organization-output.interface.ts index a18bdc95c..df9c1958c 100644 --- a/src/app/features/organization/models/organization-entity/organization-output.interface.ts +++ b/src/app/features/organization/models/organization-entity/organization-output.interface.ts @@ -36,6 +36,16 @@ export interface OrganizationOutput extends HydraItem { */ readonly name: string; + /** + * Property operatingProfile + * + * @description + * Operational defaults; absent responses from an older API use operator. + * + * @property operatingProfile + */ + readonly operatingProfile?: 'operator' | 'service_provider'; + /** * Property slug * diff --git a/src/app/features/organization/models/organization-entity/update-organization-input.interface.ts b/src/app/features/organization/models/organization-entity/update-organization-input.interface.ts index 7fcb737e1..d23721d27 100644 --- a/src/app/features/organization/models/organization-entity/update-organization-input.interface.ts +++ b/src/app/features/organization/models/organization-entity/update-organization-input.interface.ts @@ -38,6 +38,16 @@ export interface UpdateOrganizationInput { */ readonly name?: string; + /** + * Property operatingProfile + * + * @description + * Changes operational defaults without granting permissions. + * + * @property operatingProfile + */ + readonly operatingProfile?: 'operator' | 'service_provider' | null; + /** * Property slug * diff --git a/src/app/features/organization/models/park/park-anomalies-summary-output.interface.ts b/src/app/features/organization/models/park/park-anomalies-summary-output.interface.ts new file mode 100644 index 000000000..eef719ae2 --- /dev/null +++ b/src/app/features/organization/models/park/park-anomalies-summary-output.interface.ts @@ -0,0 +1,40 @@ +import type { HydraItem } from '@core/api/models'; +import type { NonConformitySeverity } from '@features/organization/features/inspections/models'; + +/** + * Interface ParkAnomaliesSummaryOutput + * @interface + * + * @description + * Server projection of unresolved anomalies in the same park scope as the equipment counts. + * + * @since unreleased + */ +export interface ParkAnomaliesSummaryOutput extends HydraItem { + /** + * Property openAnomalies + * @readonly + * + * @description + * Open or in-progress findings across the complete selected scope. + * + * @access public + * @since unreleased + * + * @type {number} + */ + readonly openAnomalies: number; + /** + * Property bySeverity + * @readonly + * + * @description + * Explicit counts by severity, including zero values. + * + * @access public + * @since unreleased + * + * @type {Readonly>} + */ + readonly bySeverity: Readonly>; +} diff --git a/src/app/features/organization/models/role/organization-permission-name.model.ts b/src/app/features/organization/models/role/organization-permission-name.model.ts index ae6993e0d..532168395 100644 --- a/src/app/features/organization/models/role/organization-permission-name.model.ts +++ b/src/app/features/organization/models/role/organization-permission-name.model.ts @@ -3,7 +3,6 @@ * * @description * Canonical organization-scoped permission names exposed by the frontend. - * * A const object is preferred over a TypeScript enum here so consumers get * autocomplete and strict typing without introducing extra runtime enum code. * @@ -24,58 +23,140 @@ export const ORGANIZATION_PERMISSION = { ROLES_MANAGE: 'organization.roles.manage', FACILITIES_READ: 'organization.facilities.read', FACILITIES_WRITE: 'organization.facilities.write', + CUSTOMERS_READ: 'organization.customers.read', + CUSTOMERS_MANAGE: 'organization.customers.manage', + SERVICE_REQUESTS_READ: 'organization.service_requests.read', + SERVICE_REQUESTS_CREATE: 'organization.service_requests.create', + SERVICE_REQUESTS_MANAGE: 'organization.service_requests.manage', + INVENTORY_READ: 'organization.inventory.read', + INVENTORY_MANAGE: 'organization.inventory.manage', + INVENTORY_CONSUME: 'organization.inventory.consume', + PROCUREMENT_READ: 'organization.procurement.read', + PROCUREMENT_MANAGE: 'organization.procurement.manage', + MAINTENANCE_COST_READ: 'organization.maintenance_cost.read', + MAINTENANCE_COST_MANAGE: 'organization.maintenance_cost.manage', + MAINTENANCE_EXPORTS_READ: 'organization.maintenance_exports.read', + MAINTENANCE_EXPORTS_MANAGE: 'organization.maintenance_exports.manage', + MAINTENANCE_EXPORTS_CONFIRM: 'organization.maintenance_exports.confirm', EQUIPMENT_READ: 'organization.equipment.read', EQUIPMENT_WRITE: 'organization.equipment.write', INSPECTION_READ: 'organization.inspection.read', INSPECTION_WRITE: 'organization.inspection.write', - /** Read access to intervention pages and intervention-linked resources. */ + /** + * @description + * Read access to intervention pages and intervention-linked resources. + */ INTERVENTIONS_READ: 'organization.interventions.read', - /** Write access to intervention entities during preparation phase. */ + /** + * @description + * Write access to intervention entities during preparation phase. + */ INTERVENTIONS_WRITE: 'organization.interventions.write', INTERVENTIONS_PLAN: 'organization.interventions.plan', INTERVENTIONS_EXECUTE: 'organization.interventions.execute', INTERVENTIONS_REVIEW: 'organization.interventions.review', - /** Permission required to trigger intervention publication. */ + /** + * @description + * Permission required to trigger intervention publication. + */ INTERVENTIONS_PUBLISH: 'organization.interventions.publish', - /** Read access to conversations, channels, direct messages and their contents. */ + /** + * @description + * Read access to conversations, channels, direct messages and their contents. + */ MESSAGING_READ: 'organization.messaging.read', - /** Permission required to post, edit and delete own messages, replies and attachments. */ + /** + * @description + * Permission required to post, edit and delete own messages, replies and attachments. + */ MESSAGING_WRITE: 'organization.messaging.write', - /** Permission required to create/archive channels, manage participants and moderate messages. */ + /** + * @description + * Permission required to create/archive channels, manage participants and moderate messages. + */ MESSAGING_MANAGE: 'organization.messaging.manage', - /** Permission required to open an assistant thread and ask questions. */ + /** + * @description + * Permission required to open an assistant thread and ask questions. + */ ASSISTANT_USE: 'organization.assistant.use', - /** Permission required to manage organization settings (general, notifications, regional). */ + /** + * @description + * Permission required to manage organization settings (general, notifications, regional). + */ AUTOMATION_READ: 'organization.automation.read', AUTOMATION_MANAGE: 'organization.automation.manage', SETTINGS_WRITE: 'organization.settings.write', - /** Read access to the compliance rollup, facility tree and per-facility summaries. */ + /** + * @description + * Read access to the compliance rollup, facility tree and per-facility summaries. + */ COMPLIANCE_READ: 'organization.compliance.read', - /** Permission required to export the safety-register PDF (the backend additionally gates it on plan tier). */ + /** + * @description + * Permission required to export the safety-register PDF (the backend additionally gates it on + * plan tier). + */ COMPLIANCE_EXPORT: 'organization.compliance.export', - /** Read access to the maintenance schedules. */ + /** + * @description + * Read access to the maintenance schedules. + */ MAINTENANCE_READ: 'organization.maintenance.read', - /** Permission required to override maintenance intervals and generate campaigns. */ + /** + * @description + * Permission required to override maintenance intervals and generate campaigns. + */ MAINTENANCE_MANAGE: 'organization.maintenance.manage', - /** Read access to teams and their membership. */ + /** + * @description + * Read access to teams and their membership. + */ TEAMS_READ: 'organization.teams.read', - /** Permission required to create and edit teams. */ + /** + * @description + * Permission required to create and edit teams. + */ TEAMS_WRITE: 'organization.teams.write', - /** Permission required to manage team membership and deletion. */ + /** + * @description + * Permission required to manage team membership and deletion. + */ TEAMS_MANAGE: 'organization.teams.manage', - /** Read access to the organization activity feed (audit events scoped to the organization). */ + /** + * @description + * Read access to the organization activity feed (audit events scoped to the organization). + */ AUDIT_READ: 'organization.audit.read', - /** Read access to four-eyes approval requests. */ + /** + * @description + * Read access to four-eyes approval requests. + */ APPROVALS_READ: 'organization.approvals.read', - /** Permission required to submit an action for four-eyes approval. */ + /** + * @description + * Permission required to submit an action for four-eyes approval. + */ APPROVALS_REQUEST: 'organization.approvals.request', - /** Permission required to approve or reject a pending approval request. */ + /** + * @description + * Permission required to approve or reject a pending approval request. + */ APPROVALS_DECIDE: 'organization.approvals.decide', - /** Read access to webhook subscriptions and their deliveries. */ + /** + * @description + * Read access to webhook subscriptions and their deliveries. + */ WEBHOOKS_READ: 'organization.webhooks.read', - /** Permission required to manage webhook subscriptions. */ + /** + * @description + * Permission required to manage webhook subscriptions. + */ WEBHOOKS_MANAGE: 'organization.webhooks.manage', - /** Permission required to permanently delete the organization. */ + /** + * @description + * Permission required to permanently delete the organization. + */ DELETE: 'organization.delete', ALL: 'organization.*', } as const; @@ -85,6 +166,8 @@ export const ORGANIZATION_PERMISSION = { * * @description * Union of all known organization-scoped permission names. + * + * @type {(typeof ORGANIZATION_PERMISSION)[keyof typeof ORGANIZATION_PERMISSION]} */ export type OrganizationPermissionName = (typeof ORGANIZATION_PERMISSION)[keyof typeof ORGANIZATION_PERMISSION]; diff --git a/src/app/features/organization/navigation/organization-mobile-navigation.config.ts b/src/app/features/organization/navigation/organization-mobile-navigation.config.ts index 25fe5b655..15e1ced9d 100644 --- a/src/app/features/organization/navigation/organization-mobile-navigation.config.ts +++ b/src/app/features/organization/navigation/organization-mobile-navigation.config.ts @@ -116,7 +116,7 @@ export function buildOrganizationMobileNavigation( const prefix = `/organizations/${organizationId}`; const organizationSections = buildOrganizationNavigation(organizationId, grantedPermissions); const organizationLinks = organizationSections.flatMap((section) => section.links); - const primaryIds = ['dashboard', 'interventions', 'assets']; + const primaryIds = ['assets', 'interventions', 'dashboard']; const canReadMessaging = Array.from(grantedPermissions).some((grant) => matchesOrganizationPermission(grant, ORGANIZATION_PERMISSION.MESSAGING_READ), ); diff --git a/src/app/features/organization/navigation/organization-navigation.config.ts b/src/app/features/organization/navigation/organization-navigation.config.ts index e2a267574..6b4ab0626 100644 --- a/src/app/features/organization/navigation/organization-navigation.config.ts +++ b/src/app/features/organization/navigation/organization-navigation.config.ts @@ -388,6 +388,15 @@ export interface OrganizationNavigationSection { * @since 1.0.0 */ export const ORGANIZATION_NAVIGATION_ITEMS: ReadonlyArray = [ + { + id: 'assets', + label: $localize`:@@route.park:Equipment park`, + icon: 'lucideNetwork', + path: 'assets', + group: 'operations', + permissions: [ORGANIZATION_PERMISSION.FACILITIES_READ], + }, + { id: 'dashboard', label: $localize`:@@route.dashboard:Dashboard`, @@ -418,6 +427,50 @@ export const ORGANIZATION_NAVIGATION_ITEMS: ReadonlyArray { it('keeps five stable primary ids in their specified order for a full-access member', () => { const navigation = buildOrganizationMobileNavigation('org-1', new Set(['organization.*'])); expect(navigation.primary.map((link) => link.id)).toEqual([ - 'dashboard', - 'interventions', 'assets', + 'interventions', + 'dashboard', 'messages', 'more', ]); expect(navigation.primary.map((link) => link.route)).toEqual([ - '/organizations/org-1', - '/organizations/org-1/interventions', '/organizations/org-1/assets', + '/organizations/org-1/interventions', + '/organizations/org-1', '/organizations/org-1/messages', '/organizations/org-1/more', ]); - expect(navigation.primary[0]?.label).toBe('Home'); + expect(navigation.primary[0]?.label).toBe('Equipment park'); }); it('preserves every allowed sidebar and switcher destination across primary and More', () => { @@ -70,6 +70,9 @@ describe('buildOrganizationMobileNavigation', () => { ?.links.map((link) => link.id), ).toEqual([ 'automations', + 'service-requests', + 'maintenance-costs', + 'maintenance-exports', 'calendar', 'inspections', 'workload', diff --git a/src/app/features/organization/organization.routes.ts b/src/app/features/organization/organization.routes.ts index 0352dcc41..7c3a69123 100644 --- a/src/app/features/organization/organization.routes.ts +++ b/src/app/features/organization/organization.routes.ts @@ -45,19 +45,16 @@ function redirectToOrganizationMembersTab(tab: 'roles' | 'teams'): RedirectFunct * * @description * The feature's route entry point, and everything served under one organization. - * * `/organizations` is redirect-only: `organizationGuard` forwards to the * default workspace — the last organization persisted in the cookie when it is * still accessible, else the first accessible one, else onboarding. Its empty * `children` exists only to make the path matchable, since the guard always * redirects and no component is ever constructed. There is no organization list * page; switching happens through the sidebar switcher. - * * `/organizations/:organizationId` resolves organization context before any * child renders, so a page never has to reason about a half-known workspace. * Its `more` child is the full secondary navigation directory, available in both * experiences under the same parent guard and resolver without additional reads. - * * The landing page — the merged Dashboard, combining the retired Today and * Statistics pages into one tabbed surface (`FEATURE.md`) — the * conversational surfaces (direct messages and channels), the estate @@ -67,14 +64,12 @@ function redirectToOrganizationMembersTab(tab: 'roles' | 'teams'): RedirectFunct * (`audit`), the administration pages (members, settings) and a member's * profile are mounted today. `statistics` is a permanent redirect to the * landing page for old bookmarks and deep links. - * * `members` now also carries what used to be the `team` (roles & * permissions) and `teams` routes, absorbed as `OrganizationMembersPage` * tabs (`?tab=roles`, `?tab=teams`) — see the page's own `@description` for * the per-tab permission story. `/team` and `/teams` stay addressable as * functional redirects ({@link redirectToOrganizationMembersTab}) so * existing links and bookmarks keep working. - * * `messages` and `channels` load the collaboration subfeature's route files * directly rather than its barrel, which also exports the offline sync * coordinator and would pull it into this lazy chunk. @@ -168,9 +163,9 @@ export const ORGANIZATION_ROUTES: Routes = [ import('./ui/pages/organization-assets-page/organization-assets-page.component').then( (m) => m.OrganizationAssetsPage, ), - title: $localize`:@@route.assets:Assets`, + title: $localize`:@@route.park:Equipment park`, data: { - breadcrumb: $localize`:@@route.assets:Assets`, + breadcrumb: $localize`:@@route.park:Equipment park`, [DASHBOARD_MOBILE_NAVIGATION_ROOT_DATA_KEY]: true, } satisfies DashboardRouteData, }, @@ -179,6 +174,45 @@ export const ORGANIZATION_ROUTES: Routes = [ loadChildren: () => import('./features/equipments/equipments.routes').then((m) => m.EQUIPMENT_ROUTES), }, + { + path: 'customers', + loadChildren: () => + import('./features/customers/customers.routes').then((m) => m.CUSTOMER_ROUTES), + }, + { + path: 'service-requests', + loadChildren: () => + import('./features/service-requests/service-requests.routes').then( + (m) => m.SERVICE_REQUEST_ROUTES, + ), + }, + { + path: 'inventory', + loadChildren: () => + import('./features/inventory/inventory.routes').then((m) => m.INVENTORY_ROUTES), + }, + { + path: 'procurement', + canActivate: [ + organizationPermissionGuard({ permissions: [ORGANIZATION_PERMISSION.PROCUREMENT_READ] }), + ], + loadChildren: () => + import('./features/procurement/procurement.routes').then((m) => m.PROCUREMENT_ROUTES), + }, + { + path: 'maintenance-costs', + loadChildren: () => + import('./features/maintenance-costs/maintenance-costs.routes').then( + (m) => m.MAINTENANCE_COST_ROUTES, + ), + }, + { + path: 'maintenance-exports', + loadChildren: () => + import('./features/maintenance-exports/maintenance-exports.routes').then( + (m) => m.MAINTENANCE_EXPORT_ROUTES, + ), + }, { path: 'facilities', loadChildren: () => diff --git a/src/app/features/organization/services/organization-landing/index.ts b/src/app/features/organization/services/organization-landing/index.ts new file mode 100644 index 000000000..89a6d2497 --- /dev/null +++ b/src/app/features/organization/services/organization-landing/index.ts @@ -0,0 +1 @@ +export { OrganizationLandingService } from './organization-landing.service'; diff --git a/src/app/features/organization/services/organization-landing/organization-landing.service.ts b/src/app/features/organization/services/organization-landing/organization-landing.service.ts new file mode 100644 index 000000000..198fb6189 --- /dev/null +++ b/src/app/features/organization/services/organization-landing/organization-landing.service.ts @@ -0,0 +1,81 @@ +import { inject, Service } from '@angular/core'; +import { map, type Observable } from 'rxjs'; +import { OrganizationPermissionService } from '@features/organization/access'; +import { ORGANIZATION_PERMISSION } from '@features/organization/models'; +import { OrganizationMemberAccessStore } from '@features/organization/state'; + +/** + * Class OrganizationLandingService + * @class OrganizationLandingService + * + * @description + * Resolves a target organization's default destination from API-confirmed permission grants. + * Reuses the route access coordinator without changing the active organization. + */ +@Service() +export class OrganizationLandingService { + //#region Properties + /** + * Property access + * @readonly + * + * @description + * Target-scoped access coordinator shared with route guards. + * + * @access private + * @since unreleased + * + * @type {OrganizationMemberAccessStore} + */ + private readonly access: OrganizationMemberAccessStore = inject(OrganizationMemberAccessStore); + + /** + * Property permissions + * @readonly + * + * @description + * Checks effective grants only after resolving the exact target organization. + * + * @access private + * @since unreleased + * + * @type {OrganizationPermissionService} + */ + private readonly permissions: OrganizationPermissionService = inject( + OrganizationPermissionService, + ); + //#endregion + + //#region Methods + /** + * Method defaultDestination + * @method defaultDestination + * + * @description + * Opens the fleet when facilities-read is granted, otherwise preserves the historical dashboard. + * Explicit return URLs remain caller-owned and need not trigger this default lookup. + * + * @access public + * @since unreleased + * + * @param {string} organizationId - Server-authorized target organization. + * + * @returns {Observable} Default local organization route. + */ + public defaultDestination(organizationId: string): Observable { + const root: string = `/organizations/${encodeURIComponent(organizationId)}`; + return this.access + .ensureAccessResolved(organizationId) + .pipe( + map((resolved) => + resolved && + this.permissions.canAccessOrganization(organizationId, [ + ORGANIZATION_PERMISSION.FACILITIES_READ, + ]) + ? `${root}/assets` + : root, + ), + ); + } + //#endregion +} diff --git a/src/app/features/organization/services/organization-landing/testing/organization-landing.service.spec.ts b/src/app/features/organization/services/organization-landing/testing/organization-landing.service.spec.ts new file mode 100644 index 000000000..cb35d6898 --- /dev/null +++ b/src/app/features/organization/services/organization-landing/testing/organization-landing.service.spec.ts @@ -0,0 +1,52 @@ +import { TestBed } from '@angular/core/testing'; +import { firstValueFrom, of } from 'rxjs'; +import { OrganizationPermissionService } from '@features/organization/access'; +import { ORGANIZATION_PERMISSION } from '@features/organization/models'; +import { OrganizationMemberAccessStore } from '@features/organization/state'; +import { OrganizationLandingService } from '../organization-landing.service'; + +describe('OrganizationLandingService', () => { + let ensureAccessResolved: ReturnType; + let canAccessOrganization: ReturnType; + beforeEach(() => { + ensureAccessResolved = vi.fn().mockReturnValue(of(true)); + canAccessOrganization = vi.fn().mockReturnValue(true); + TestBed.configureTestingModule({ + providers: [ + OrganizationLandingService, + { provide: OrganizationMemberAccessStore, useValue: { ensureAccessResolved } }, + { provide: OrganizationPermissionService, useValue: { canAccessOrganization } }, + ], + }); + }); + + it('uses only API-confirmed facilities permission for the target organization', async () => { + const destination = await firstValueFrom( + TestBed.inject(OrganizationLandingService).defaultDestination('org-selected'), + ); + expect(ensureAccessResolved).toHaveBeenCalledWith('org-selected'); + expect(canAccessOrganization).toHaveBeenCalledWith('org-selected', [ + ORGANIZATION_PERMISSION.FACILITIES_READ, + ]); + expect(destination).toBe('/organizations/org-selected/assets'); + }); + + it('keeps the dashboard fallback when the target member cannot read facilities', async () => { + canAccessOrganization.mockReturnValue(false); + expect( + await firstValueFrom( + TestBed.inject(OrganizationLandingService).defaultDestination('org-selected'), + ), + ).toBe('/organizations/org-selected'); + }); + + it('never reuses another organization permissions when target access is unresolved', async () => { + ensureAccessResolved.mockReturnValue(of(false)); + expect( + await firstValueFrom( + TestBed.inject(OrganizationLandingService).defaultDestination('org-selected'), + ), + ).toBe('/organizations/org-selected'); + expect(canAccessOrganization).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/features/organization/setup/index.ts b/src/app/features/organization/setup/index.ts index 56987ad6f..81f0a15fd 100644 --- a/src/app/features/organization/setup/index.ts +++ b/src/app/features/organization/setup/index.ts @@ -7,6 +7,7 @@ export type { SetupCreateInspectionInput, SetupCreateOrganizationInput, SetupEquipmentSummary, + SetupEquipmentTypeOption, SetupFacilitySummary, SetupFacilityAddressMatch, SetupFacilityType, diff --git a/src/app/features/organization/setup/organization-setup.service.ts b/src/app/features/organization/setup/organization-setup.service.ts index 752454bd7..fb34071a8 100644 --- a/src/app/features/organization/setup/organization-setup.service.ts +++ b/src/app/features/organization/setup/organization-setup.service.ts @@ -6,7 +6,11 @@ import { OrganizationRoleService, OrganizationService, } from '@features/organization/data-access'; -import { EquipmentService } from '@features/organization/features/equipments/data-access'; +import { + EquipmentService, + EquipmentTypeService, +} from '@features/organization/features/equipments/data-access'; +import { equipmentTypeOption } from '@features/organization/features/equipments/utils'; import { FacilityService } from '@features/organization/features/facilities/data-access'; import { InspectionService } from '@features/organization/features/inspections/data-access'; import type { @@ -16,6 +20,7 @@ import type { SetupCreateInspectionInput, SetupCreateOrganizationInput, SetupEquipmentSummary, + SetupEquipmentTypeOption, SetupFacilitySummary, SetupFacilityAddressMatch, SetupInviteMemberInput, @@ -33,6 +38,7 @@ import type { * payloads or services. * * @version 1.0.0 + * * @author Valentin FORTIN */ @Service() @@ -110,6 +116,20 @@ export class OrganizationSetupService { */ private readonly equipmentService: EquipmentService = inject(EquipmentService); + /** + * Property equipmentTypeService + * @readonly + * + * @description + * Reads the complete server-owned equipment catalogue through its owning transport. + * + * @access private + * @since unreleased + * + * @type {EquipmentTypeService} + */ + private readonly equipmentTypeService: EquipmentTypeService = inject(EquipmentTypeService); + /** * Property inspectionService * @readonly @@ -132,8 +152,13 @@ export class OrganizationSetupService { * Creates an organization through the setup boundary and hides the internal * transport payload from consumers. * + * @access public + * @since unknown + * * @param {SetupCreateOrganizationInput} input Organization creation payload. - * @param {SetupOperationContext | undefined} context - Optional server receipt for exactly one resource creation. + * @param {SetupOperationContext | undefined} context - Optional server receipt for exactly one + * resource creation. + * * @returns {Observable} Observable completing when the organization has been created. */ public createOrganization( @@ -150,8 +175,13 @@ export class OrganizationSetupService { * Lists assignable organization roles and maps them to the setup-owned DTO * shape exposed to consumers. * + * @access public + * @since unknown + * * @param {string} organizationId Target organization identifier. - * @returns {Observable} Observable emitting setup role summaries. + * + * @returns {Observable} Observable emitting setup role + * summaries. */ public listRoles(organizationId: string): Observable { return this.organizationRoleService.listAll(organizationId).pipe( @@ -172,9 +202,14 @@ export class OrganizationSetupService { * Sends one or more member invitations for the target organization through * the setup boundary. * + * @access public + * @since unknown + * * @param {string} organizationId Target organization identifier. * @param {readonly SetupInviteMemberInput[]} invitations Invitations to create. - * @param {SetupOperationContext | undefined} context - Optional server receipt for exactly one resource creation. + * @param {SetupOperationContext | undefined} context - Optional server receipt for exactly one + * resource creation. + * * @returns {Observable} Observable completing when all invitations have been sent. */ public inviteMembers( @@ -199,7 +234,21 @@ export class OrganizationSetupService { ).pipe(map(() => undefined)); } - /** @description Lists all persisted sites as setup summaries so an activation resumed after reload can attach equipment. */ + /** + * Method listFacilities + * @method listFacilities + * + * @description + * Lists persisted site summaries so resumed activation can attach equipment without duplicating + * sites. + * + * @access public + * @since unknown + * + * @param {string} organizationId - Organization owning the persisted sites. + * + * @returns {Observable} Complete site choices. + */ public listFacilities(organizationId: string): Observable { return this.facilityService .listAll(organizationId) @@ -209,12 +258,19 @@ export class OrganizationSetupService { /** * Method searchFacilityAddresses * @method searchFacilityAddresses - * @description Retrieves address suggestions through the facility-owned search provider without exposing its transport envelope to onboarding. + * + * @description + * Retrieves address suggestions through the facility-owned search provider without exposing its + * transport envelope to onboarding. + * * @access public * @since 1.0.0 + * * @param {string} organizationId - Organization owning the new facility. * @param {string} query - Postal address fragment entered by the user. - * @returns {Observable} Suggested addresses, preserving upstream HTTP errors. + * + * @returns {Observable} Suggested addresses, preserving + * upstream HTTP errors. */ public searchFacilityAddresses( organizationId: string, @@ -257,10 +313,16 @@ export class OrganizationSetupService { * their setup-owned summaries so consumers can reference the created * resources — onboarding attaches the first equipment to one of them. * + * @access public + * @since unknown + * * @param {string} organizationId Target organization identifier. * @param {readonly SetupCreateFacilityInput[]} facilities Facilities to create. - * @param {SetupOperationContext | undefined} context - Optional server receipt for exactly one resource creation. - * @returns {Observable} Observable emitting the created facility summaries. + * @param {SetupOperationContext | undefined} context - Optional server receipt for exactly one + * resource creation. + * + * @returns {Observable} Observable emitting the created facility + * summaries. */ public createFacilities( organizationId: string, @@ -280,6 +342,41 @@ export class OrganizationSetupService { ); } + /** + * Method listEquipmentTypes + * @method listEquipmentTypes + * + * @description + * Publishes active localized catalogue choices without exposing Equipment transport metadata. + * Archived types cannot be selected for a new setup creation. + * + * @access public + * @since unreleased + * + * @param {string} organizationId - Organization owning the catalogue. + * + * @returns {Observable} Complete active catalogue. + */ + public listEquipmentTypes( + organizationId: string, + ): Observable { + return this.equipmentTypeService.listAll(organizationId).pipe( + map((entries) => + entries + .filter((entry) => !entry.archived) + .map((entry) => { + const option = equipmentTypeOption(entry); + return { + value: option.value, + label: option.label, + family: option.family, + icon: option.icon, + }; + }), + ), + ); + } + /** * Method listEquipment * @@ -287,9 +384,14 @@ export class OrganizationSetupService { * Lists equipment records needed during setup flows and maps them to the * setup-owned summary DTO. * + * @access public + * @since unknown + * * @param {string} organizationId Target organization identifier. * @param {number} [itemsPerPage=100] Maximum number of items requested from the backend. - * @returns {Observable} Observable emitting equipment summaries. + * + * @returns {Observable} Observable emitting equipment + * summaries. */ public listEquipment( organizationId: string, @@ -314,9 +416,14 @@ export class OrganizationSetupService { * setup boundary. An optional `facilityId` is mapped to the flat facility * IRI the API validates, assigning the equipment in the same request. * + * @access public + * @since unknown + * * @param {string} organizationId Target organization identifier. * @param {SetupCreateEquipmentInput} input Equipment creation payload. - * @param {SetupOperationContext | undefined} context - Optional server receipt for exactly one resource creation. + * @param {SetupOperationContext | undefined} context - Optional server receipt for exactly one + * resource creation. + * * @returns {Observable} Observable completing when the equipment has been created. */ public createEquipment( @@ -342,8 +449,12 @@ export class OrganizationSetupService { * Creates an inspection record for setup flows without exposing the internal * organization inspection payloads to consumers. * + * @access public + * @since unknown + * * @param {string} organizationId Target organization identifier. * @param {SetupCreateInspectionInput} input Inspection creation payload. + * * @returns {Observable} Observable completing when the inspection has been created. */ public createInspection( diff --git a/src/app/features/organization/setup/organization-setup.types.ts b/src/app/features/organization/setup/organization-setup.types.ts index 29cd5a9d4..428f39823 100644 --- a/src/app/features/organization/setup/organization-setup.types.ts +++ b/src/app/features/organization/setup/organization-setup.types.ts @@ -34,6 +34,17 @@ export interface SetupCreateOrganizationInput { */ readonly name: string; + /** + * Property operatingProfile + * @readonly + * + * @description + * Operational defaults retained in the durable organization creation draft. + * + * @type {'operator' | 'service_provider' | undefined} + */ + readonly operatingProfile?: 'operator' | 'service_provider'; + /** * Property slug * @readonly @@ -515,6 +526,60 @@ export interface SetupCreateEquipmentInput { readonly facilityId?: string | null; } +/** + * Interface SetupEquipmentTypeOption + * @interface SetupEquipmentTypeOption + * + * @description + * Active catalogue choice published by setup; codes are open to organization-defined equipment + * types. + */ +export interface SetupEquipmentTypeOption { + /** + * Property value + * @readonly + * + * @description + * Permanent server-owned equipment type code. + * + * @type {string} + */ + readonly value: string; + + /** + * Property label + * @readonly + * + * @description + * Localized historical label or organization-defined display name. + * + * @type {string} + */ + readonly label: string; + + /** + * Property family + * @readonly + * + * @description + * Server-assigned family used to focus the initial fire inventory. + * + * @type {'fire' | 'safety' | 'other'} + */ + readonly family: 'fire' | 'safety' | 'other'; + + /** + * Property icon + * @readonly + * + * @description + * Registered equipment glyph with a generic fallback for custom codes. + * + * @type {string} + */ + readonly icon: string; +} + /** * Type SetupInspectionResult * diff --git a/src/app/features/organization/setup/testing/organization-setup.service.spec.ts b/src/app/features/organization/setup/testing/organization-setup.service.spec.ts index 94814936f..d51187b7e 100644 --- a/src/app/features/organization/setup/testing/organization-setup.service.spec.ts +++ b/src/app/features/organization/setup/testing/organization-setup.service.spec.ts @@ -6,7 +6,10 @@ import { OrganizationRoleService, OrganizationService, } from '@features/organization/data-access'; -import { EquipmentService } from '@features/organization/features/equipments/data-access'; +import { + EquipmentService, + EquipmentTypeService, +} from '@features/organization/features/equipments/data-access'; import { FacilityService } from '@features/organization/features/facilities/data-access'; import { InspectionService } from '@features/organization/features/inspections/data-access'; import { OrganizationSetupService } from '../organization-setup.service'; @@ -41,6 +44,7 @@ describe('OrganizationSetupService', () => { list: vi.fn(), create: vi.fn(), }; + const equipmentTypeService = { listAll: vi.fn() }; const inspectionService = { create: vi.fn(), }; @@ -56,6 +60,7 @@ describe('OrganizationSetupService', () => { { provide: OrganizationRoleService, useValue: organizationRoleService }, { provide: FacilityService, useValue: facilityService }, { provide: EquipmentService, useValue: equipmentService }, + { provide: EquipmentTypeService, useValue: equipmentTypeService }, { provide: InspectionService, useValue: inspectionService }, ], }); @@ -73,6 +78,52 @@ describe('OrganizationSetupService', () => { expect(organizationService.create).toHaveBeenCalledWith({ name: 'Fireguard' }); }); + it('publishes complete active custom types while keeping archived descriptors unselectable', () => { + equipmentTypeService.listAll.mockReturnValue( + of([ + { + '@id': '/types/custom_fire', + '@type': 'EquipmentType', + value: 'custom_fire', + label: 'Custom fire system', + family: 'fire', + archived: false, + revision: 2, + }, + { + '@id': '/types/old', + '@type': 'EquipmentType', + value: 'old', + label: 'Archived type', + family: 'other', + archived: true, + revision: 4, + }, + ]), + ); + const received: unknown[] = []; + service.listEquipmentTypes('org-1').subscribe((types) => received.push(types)); + expect(equipmentTypeService.listAll).toHaveBeenCalledWith('org-1'); + expect(received).toEqual([ + [ + { + value: 'custom_fire', + label: 'Custom fire system', + family: 'fire', + icon: 'lucideBox', + }, + ], + ]); + }); + + it('propagates catalogue read errors for a visible explicit retry', () => { + const failure = new Error('Catalogue unavailable'); + equipmentTypeService.listAll.mockReturnValue(throwError(() => failure)); + const rejected = vi.fn(); + service.listEquipmentTypes('org-1').subscribe({ error: rejected }); + expect(rejected).toHaveBeenCalledWith(failure); + }); + it('should map organization roles to the setup contract', () => { organizationRoleService.listAll.mockReturnValue( of([ diff --git a/src/app/features/organization/state/organization-assets-pane/models/state.interface.ts b/src/app/features/organization/state/organization-assets-pane/models/state.interface.ts index be51be5af..29bbfb024 100644 --- a/src/app/features/organization/state/organization-assets-pane/models/state.interface.ts +++ b/src/app/features/organization/state/organization-assets-pane/models/state.interface.ts @@ -1,6 +1,9 @@ import type { CallState } from '@core/request-state'; import type { EquipmentOutput } from '@features/organization/features/equipments/models'; -import type { InspectionOutput } from '@features/organization/features/inspections/models'; +import type { + InspectionOutput, + NonConformityOutput, +} from '@features/organization/features/inspections/models'; /** * Interface OrganizationAssetsPaneState @@ -16,6 +19,59 @@ import type { InspectionOutput } from '@features/organization/features/inspectio * @author Valentin FORTIN */ export interface OrganizationAssetsPaneState { + /** + * Property anomaliesPage + * @readonly + * + * @description + * Server page of the unresolved anomaly register. + * + * @access public + * @since unreleased + * + * @type {number} + */ + readonly anomaliesPage: number; + /** + * Property anomaliesTotal + * @readonly + * + * @description + * Exact filtered anomaly count across all server pages. + * + * @access public + * @since unreleased + * + * @type {number} + */ + readonly anomaliesTotal: number; + /** + * Property anomaliesScope + * @readonly + * + * @description + * Scope fingerprint cancels and clears obsolete client/family/site results. + * + * @access public + * @since unreleased + * + * @type {string} + */ + readonly anomaliesScope: string; + /** + * Property anomaliesCallState + * @readonly + * + * @description + * Independent list lifecycle; unknown is distinct from a verified empty register. + * + * @access public + * @since unreleased + * + * @type {CallState} + */ + readonly anomaliesCallState: CallState; + /** * Property equipmentPage * @readonly diff --git a/src/app/features/organization/state/organization-assets-pane/organization-assets-pane.store.ts b/src/app/features/organization/state/organization-assets-pane/organization-assets-pane.store.ts index 3c711bb83..df8de937b 100644 --- a/src/app/features/organization/state/organization-assets-pane/organization-assets-pane.store.ts +++ b/src/app/features/organization/state/organization-assets-pane/organization-assets-pane.store.ts @@ -13,6 +13,7 @@ import { successCallState, toStoreError, } from '@core/request-state'; +import { ParkService } from '@features/organization/data-access'; import { EquipmentService } from '@features/organization/features/equipments/data-access'; import type { EquipmentOutput } from '@features/organization/features/equipments/models'; import { InspectionService } from '@features/organization/features/inspections/data-access'; @@ -36,6 +37,10 @@ const PANE_ITEMS_PER_PAGE = 50; * @since 1.0.0 */ const INITIAL_STATE: OrganizationAssetsPaneState = { + anomaliesPage: 1, + anomaliesTotal: 0, + anomaliesScope: '', + anomaliesCallState: idleCallState(), equipmentPage: 1, equipmentTotal: 0, equipmentScope: '', @@ -80,6 +85,10 @@ export const OrganizationAssetsPaneStore = signalStore( //#region Computed withComputed((store) => ({ + anomalies: computed(() => store.anomaliesCallState().data ?? []), + anomaliesPageCount: computed(() => + Math.max(1, Math.ceil(store.anomaliesTotal() / PANE_ITEMS_PER_PAGE)), + ), equipmentPageCount: computed(() => Math.max(1, Math.ceil(store.equipmentTotal() / PANE_ITEMS_PER_PAGE)), ), @@ -133,9 +142,79 @@ export const OrganizationAssetsPaneStore = signalStore( withMethods( ( store, + parkService = inject(ParkService), equipmentService = inject(EquipmentService), inspectionService = inject(InspectionService), ) => ({ + /** + * @description + * Reads the unresolved anomaly register with the same family/customer/site authority as + * counts. + */ + loadAnomalies: rxMethod<{ + readonly organizationId: string; + readonly facilityId?: string; + readonly family?: string; + readonly customerId?: string; + readonly includeDescendants?: boolean; + readonly page?: number; + }>( + pipe( + switchMap( + ({ + organizationId, + facilityId, + family, + customerId, + includeDescendants = true, + page = 1, + }) => { + const scope = JSON.stringify([ + organizationId, + facilityId ?? '', + family ?? '', + customerId ?? '', + facilityId ? includeDescendants : '', + ]); + patchState(store, { + anomaliesPage: page, + anomaliesScope: scope, + anomaliesTotal: store.anomaliesScope() === scope ? store.anomaliesTotal() : 0, + anomaliesCallState: pendingCallState( + store.anomaliesScope() === scope ? store.anomaliesCallState().data : null, + ), + }); + return parkService + .anomalies(organizationId, { + page, + itemsPerPage: PANE_ITEMS_PER_PAGE, + params: { + ...(facilityId ? { facilityId, includeDescendants } : {}), + ...(family ? { family } : {}), + ...(customerId ? { customerId } : {}), + }, + }) + .pipe( + tapResponse({ + next: (collection) => + patchState(store, { + anomaliesTotal: collection.totalItems, + anomaliesCallState: successCallState(collection.member), + }), + error: (error: unknown) => + patchState(store, { + anomaliesCallState: errorCallState( + toStoreError(error), + store.anomaliesCallState().data, + ), + }), + }), + ); + }, + ), + ), + ), + /** * Method loadEquipment * @@ -153,47 +232,79 @@ export const OrganizationAssetsPaneStore = signalStore( readonly facilityId?: string; readonly page?: number; readonly includeDescendants?: boolean; + readonly family?: string; + readonly customerId?: string; + readonly status?: string; + readonly maintenanceDueStatus?: string; }>( pipe( - switchMap(({ organizationId, facilityId, page = 1, includeDescendants = true }) => { - patchState(store, { - equipmentPage: page, - equipmentScope: `${organizationId}:${facilityId ?? ''}:${includeDescendants}`, - equipmentListCallState: pendingCallState( - store.equipmentScope() === - `${organizationId}:${facilityId ?? ''}:${includeDescendants}` - ? store.equipmentListCallState().data - : null, - ), - }); + switchMap( + ({ + organizationId, + facilityId, + page = 1, + includeDescendants = true, + family, + customerId, + status, + maintenanceDueStatus, + }) => { + const filters = { + ...(family ? { family } : {}), + ...(customerId ? { customerId } : {}), + ...(status ? { status } : {}), + ...(maintenanceDueStatus ? { maintenanceDueStatus } : {}), + }; + const scope = JSON.stringify([ + organizationId, + facilityId ?? '', + includeDescendants, + family ?? '', + customerId ?? '', + status ?? '', + maintenanceDueStatus ?? '', + ]); + patchState(store, { + equipmentPage: page, + equipmentScope: scope, + equipmentTotal: store.equipmentScope() === scope ? store.equipmentTotal() : 0, + equipmentListCallState: pendingCallState( + store.equipmentScope() === scope ? store.equipmentListCallState().data : null, + ), + }); - const request = facilityId - ? equipmentService.listByFacility(organizationId, facilityId, { - page, - itemsPerPage: PANE_ITEMS_PER_PAGE, - params: { includeDescendants }, - }) - : equipmentService.list(organizationId, { page, itemsPerPage: PANE_ITEMS_PER_PAGE }); - - return request.pipe( - tapResponse({ - next: (collection: HydraCollection): void => { - patchState(store, { - equipmentTotal: collection.totalItems, - equipmentListCallState: successCallState(collection.member), - }); - }, - error: (error: unknown): void => { - patchState(store, { - equipmentListCallState: errorCallState( - toStoreError(error), - store.equipmentListCallState().data, - ), + const request = facilityId + ? equipmentService.listByFacility(organizationId, facilityId, { + page, + itemsPerPage: PANE_ITEMS_PER_PAGE, + params: { includeDescendants, ...filters }, + }) + : equipmentService.list(organizationId, { + page, + itemsPerPage: PANE_ITEMS_PER_PAGE, + ...(Object.keys(filters).length ? { params: filters } : {}), }); - }, - }), - ); - }), + + return request.pipe( + tapResponse({ + next: (collection: HydraCollection): void => { + patchState(store, { + equipmentTotal: collection.totalItems, + equipmentListCallState: successCallState(collection.member), + }); + }, + error: (error: unknown): void => { + patchState(store, { + equipmentListCallState: errorCallState( + toStoreError(error), + store.equipmentListCallState().data, + ), + }); + }, + }), + ); + }, + ), ), ), @@ -213,45 +324,66 @@ export const OrganizationAssetsPaneStore = signalStore( readonly organizationId: string; readonly facilityId?: string; readonly page?: number; + readonly family?: string; + readonly customerId?: string; + readonly includeDescendants?: boolean; }>( pipe( - switchMap(({ organizationId, facilityId, page = 1 }) => { - patchState(store, { - inspectionPage: page, - inspectionScope: `${organizationId}:${facilityId ?? ''}`, - inspectionListCallState: pendingCallState( - store.inspectionScope() === `${organizationId}:${facilityId ?? ''}` - ? store.inspectionListCallState().data - : null, - ), - }); + switchMap( + ({ organizationId, facilityId, page = 1, family, customerId, includeDescendants }) => { + const filters = { + ...(family ? { family } : {}), + ...(customerId ? { customerId } : {}), + ...(facilityId && includeDescendants !== undefined ? { includeDescendants } : {}), + }; + const scope = JSON.stringify([ + organizationId, + facilityId ?? '', + family ?? '', + customerId ?? '', + facilityId ? (includeDescendants ?? '') : '', + ]); + patchState(store, { + inspectionPage: page, + inspectionScope: scope, + inspectionTotal: store.inspectionScope() === scope ? store.inspectionTotal() : 0, + inspectionListCallState: pendingCallState( + store.inspectionScope() === scope ? store.inspectionListCallState().data : null, + ), + }); - const request = facilityId - ? inspectionService.listByFacility(organizationId, facilityId, { - page, - itemsPerPage: PANE_ITEMS_PER_PAGE, - }) - : inspectionService.list(organizationId, { page, itemsPerPage: PANE_ITEMS_PER_PAGE }); - - return request.pipe( - tapResponse({ - next: (collection: HydraCollection): void => { - patchState(store, { - inspectionTotal: collection.totalItems, - inspectionListCallState: successCallState(collection.member), - }); - }, - error: (error: unknown): void => { - patchState(store, { - inspectionListCallState: errorCallState( - toStoreError(error), - store.inspectionListCallState().data, - ), + const request = facilityId + ? inspectionService.listByFacility(organizationId, facilityId, { + page, + itemsPerPage: PANE_ITEMS_PER_PAGE, + ...(Object.keys(filters).length ? { params: filters } : {}), + }) + : inspectionService.list(organizationId, { + page, + itemsPerPage: PANE_ITEMS_PER_PAGE, + ...(Object.keys(filters).length ? { params: filters } : {}), }); - }, - }), - ); - }), + + return request.pipe( + tapResponse({ + next: (collection: HydraCollection): void => { + patchState(store, { + inspectionTotal: collection.totalItems, + inspectionListCallState: successCallState(collection.member), + }); + }, + error: (error: unknown): void => { + patchState(store, { + inspectionListCallState: errorCallState( + toStoreError(error), + store.inspectionListCallState().data, + ), + }); + }, + }), + ); + }, + ), ), ), }), diff --git a/src/app/features/organization/state/organization-assets-pane/testing/organization-assets-pane.store.spec.ts b/src/app/features/organization/state/organization-assets-pane/testing/organization-assets-pane.store.spec.ts index ad0224859..a14c5c1a2 100644 --- a/src/app/features/organization/state/organization-assets-pane/testing/organization-assets-pane.store.spec.ts +++ b/src/app/features/organization/state/organization-assets-pane/testing/organization-assets-pane.store.spec.ts @@ -1,10 +1,12 @@ import { TestBed } from '@angular/core/testing'; -import { of, throwError } from 'rxjs'; +import { of, Subject, throwError } from 'rxjs'; import type { ApiError, HydraCollection } from '@core/api/models'; +import { ParkService } from '@features/organization/data-access'; import { EquipmentService } from '@features/organization/features/equipments/data-access'; import type { EquipmentOutput } from '@features/organization/features/equipments/models'; import { InspectionService } from '@features/organization/features/inspections/data-access'; import type { InspectionOutput } from '@features/organization/features/inspections/models'; +import type { NonConformityOutput } from '@features/organization/features/inspections/models'; import { OrganizationAssetsPaneStore, type OrganizationAssetsPaneStoreType, @@ -25,6 +27,7 @@ const apiError = (status: number, detail: string): ApiError => ({ describe('OrganizationAssetsPaneStore', () => { let store: OrganizationAssetsPaneStoreType; + let mockParkService: { anomalies: ReturnType }; let mockEquipmentService: { list: ReturnType; listByFacility: ReturnType; @@ -50,7 +53,16 @@ describe('OrganizationAssetsPaneStore', () => { member: [inspection], }; + const anomaly = { id: 'child-anomaly' } as unknown as NonConformityOutput; + const anomaliesCollection: HydraCollection = { + '@id': '/api/organizations/org-1/park-anomalies', + '@type': 'Collection', + totalItems: 1, + member: [anomaly], + }; + beforeEach(() => { + mockParkService = { anomalies: vi.fn().mockReturnValue(of(anomaliesCollection)) }; mockEquipmentService = { list: vi.fn().mockReturnValue(of(equipmentCollection)), listByFacility: vi.fn().mockReturnValue(of(equipmentCollection)), @@ -63,6 +75,7 @@ describe('OrganizationAssetsPaneStore', () => { TestBed.configureTestingModule({ providers: [ OrganizationAssetsPaneStore, + { provide: ParkService, useValue: mockParkService }, { provide: EquipmentService, useValue: mockEquipmentService }, { provide: InspectionService, useValue: mockInspectionService }, ], @@ -71,6 +84,51 @@ describe('OrganizationAssetsPaneStore', () => { store = TestBed.inject(OrganizationAssetsPaneStore); }); + it('retains direct anomaly scope, customer and family on subsequent pages', () => { + mockParkService.anomalies.mockReturnValue(of({ ...anomaliesCollection, totalItems: 123 })); + store.loadAnomalies({ + organizationId: 'org-1', + facilityId: 'site', + family: 'fire', + customerId: 'customer', + includeDescendants: false, + page: 3, + }); + expect(mockParkService.anomalies).toHaveBeenCalledExactlyOnceWith('org-1', { + page: 3, + itemsPerPage: 50, + params: { + facilityId: 'site', + includeDescendants: false, + family: 'fire', + customerId: 'customer', + }, + }); + expect(store.anomaliesTotal()).toBe(123); + expect(store.anomaliesPage()).toBe(3); + }); + + it('clears subtree findings and totals while the direct anomaly scope loads', () => { + const direct = new Subject>(); + store.loadAnomalies({ organizationId: 'org-1', facilityId: 'site' }); + expect(mockParkService.anomalies).toHaveBeenLastCalledWith('org-1', { + page: 1, + itemsPerPage: 50, + params: { facilityId: 'site', includeDescendants: true }, + }); + expect(store.anomalies()).toEqual([anomaly]); + expect(store.anomaliesTotal()).toBe(1); + mockParkService.anomalies.mockReturnValueOnce(direct); + store.loadAnomalies({ organizationId: 'org-1', facilityId: 'site', includeDescendants: false }); + expect(store.anomaliesCallState().status).toBe('pending'); + expect(store.anomalies()).toEqual([]); + expect(store.anomaliesTotal()).toBe(0); + direct.next({ ...anomaliesCollection, member: [], totalItems: 0 }); + expect(store.anomaliesCallState().status).toBe('success'); + expect(store.anomalies()).toEqual([]); + expect(store.anomaliesTotal()).toBe(0); + }); + it('loads organization-wide equipment when no facility is given', async () => { store.loadEquipment({ organizationId: 'org-1' }); await flushEffects(); @@ -102,6 +160,22 @@ describe('OrganizationAssetsPaneStore', () => { expect(store.inspections()).toEqual([inspection]); }); + it('omits subtree traversal from organization-wide inspection requests even for a legacy caller', () => { + store.loadInspections({ + organizationId: 'org-1', + family: 'fire', + customerId: 'customer', + includeDescendants: true, + page: 2, + }); + expect(mockInspectionService.list).toHaveBeenCalledExactlyOnceWith('org-1', { + page: 2, + itemsPerPage: 50, + params: { family: 'fire', customerId: 'customer' }, + }); + expect(mockInspectionService.listByFacility).not.toHaveBeenCalled(); + }); + it('keeps the direct equipment scope across pages and reports its full total', async () => { mockEquipmentService.listByFacility.mockReturnValue( of({ ...equipmentCollection, totalItems: 251 }), @@ -130,4 +204,50 @@ describe('OrganizationAssetsPaneStore', () => { expect(store.hasEquipmentError()).toBe(true); }); + + it('uses the same family and customer filters on every equipment page and the due queue', () => { + store.loadEquipment({ + organizationId: 'org-1', + facilityId: 'site', + family: 'fire', + customerId: 'customer', + maintenanceDueStatus: 'due', + page: 2, + }); + expect(mockEquipmentService.listByFacility).toHaveBeenLastCalledWith('org-1', 'site', { + page: 2, + itemsPerPage: 50, + params: { + includeDescendants: true, + family: 'fire', + customerId: 'customer', + maintenanceDueStatus: 'due', + }, + }); + store.loadInspections({ + organizationId: 'org-1', + facilityId: 'site', + family: 'fire', + customerId: 'customer', + includeDescendants: true, + }); + expect(mockInspectionService.listByFacility).toHaveBeenLastCalledWith('org-1', 'site', { + page: 1, + itemsPerPage: 50, + params: { family: 'fire', customerId: 'customer', includeDescendants: true }, + }); + }); + it('cancels obsolete customer reads and clears previous totals while the new scope loads', () => { + const old = new Subject>(); + const next = new Subject>(); + store.loadEquipment({ organizationId: 'org-1', family: 'fire', customerId: 'first' }); + mockEquipmentService.list.mockReturnValueOnce(old).mockReturnValueOnce(next); + store.loadEquipment({ organizationId: 'org-1', family: 'fire', customerId: 'first', page: 2 }); + store.loadEquipment({ organizationId: 'org-1', family: 'fire', customerId: 'second' }); + expect(old.observed).toBe(false); + expect(store.equipment()).toEqual([]); + expect(store.equipmentTotal()).toBe(0); + next.next({ ...equipmentCollection, totalItems: 200 }); + expect(store.equipmentTotal()).toBe(200); + }); }); diff --git a/src/app/features/organization/state/organization-park/index.ts b/src/app/features/organization/state/organization-park/index.ts new file mode 100644 index 000000000..519720bc9 --- /dev/null +++ b/src/app/features/organization/state/organization-park/index.ts @@ -0,0 +1,6 @@ +export { OrganizationParkStore } from './organization-park.store'; +export type { + OrganizationParkStoreType, + OrganizationParkQuery, + ParkSiteQuery, +} from './organization-park.store'; diff --git a/src/app/features/organization/state/organization-park/organization-park.store.ts b/src/app/features/organization/state/organization-park/organization-park.store.ts new file mode 100644 index 000000000..f537198e9 --- /dev/null +++ b/src/app/features/organization/state/organization-park/organization-park.store.ts @@ -0,0 +1,498 @@ +import { computed, inject } from '@angular/core'; +import { tapResponse } from '@ngrx/operators'; +import { patchState, signalStore, withComputed, withMethods, withState } from '@ngrx/signals'; +import { rxMethod } from '@ngrx/signals/rxjs-interop'; +import { EMPTY, merge, pipe, switchMap } from 'rxjs'; +import type { HydraCollection } from '@core/api/models'; +import { + errorCallState, + idleCallState, + pendingCallState, + successCallState, + toStoreError, + type CallState, +} from '@core/request-state'; +import { ParkService } from '@features/organization/data-access'; +import { EquipmentService } from '@features/organization/features/equipments/data-access'; +import { FacilityService } from '@features/organization/features/facilities/data-access'; +import type { FacilityOutput } from '@features/organization/features/facilities/models'; +import { toFacilityOption } from '@features/organization/features/facilities/utils'; + +/** + * Interface OrganizationParkQuery + * @interface + * + * @description + * Immutable authority shared by all three action counts and their destinations. + * + * @since unreleased + */ +export interface OrganizationParkQuery { + /** + * Property organizationId + * @readonly + * + * @description + * Owning organization used as the authority for every scoped request. + * + * @access public + * @since unreleased + * + * @type {string} + */ + readonly organizationId: string; + /** + * Property family + * @readonly + * + * @description + * Server catalog family; omitted for the complete equipment park. + * + * @access public + * @since unreleased + * + * @type {string} + */ + readonly family?: string; + /** + * Property customerId + * @readonly + * + * @description + * Optional internal customer narrowing the root site context. + * + * @access public + * @since unreleased + * + * @type {string} + */ + readonly customerId?: string; + /** + * Property facilityId + * @readonly + * + * @description + * Optional root site whose published descendants belong to the query. + * + * @access public + * @since unreleased + * + * @type {string} + */ + readonly facilityId?: string; + /** + * Property equipmentEnabled + * @readonly + * + * @description + * Whether the caller grants equipment reads for both equipment queues. + * + * @access public + * @since unreleased + * + * @type {boolean} + */ + readonly equipmentEnabled: boolean; + /** + * Property anomaliesEnabled + * @readonly + * + * @description + * Whether both inspection and equipment read permissions are granted. + * + * @access public + * @since unreleased + * + * @type {boolean} + */ + readonly anomaliesEnabled: boolean; +} +/** + * Interface ParkSiteQuery + * @interface + * + * @description + * Root-site chooser uses the same customer context and server search/pagination. + * + * @since unreleased + */ +export interface ParkSiteQuery { + /** + * Property organizationId + * @readonly + * + * @description + * Owning organization used as the authority for every scoped request. + * + * @access public + * @since unreleased + * + * @type {string} + */ + readonly organizationId: string; + /** + * Property customerId + * @readonly + * + * @description + * Optional internal customer narrowing the root site context. + * + * @access public + * @since unreleased + * + * @type {string} + */ + readonly customerId?: string; + /** + * Property search + * @readonly + * + * @description + * Server search over root-site names and paths. + * + * @access public + * @since unreleased + * + * @type {string} + */ + readonly search?: string; + /** + * Property page + * @readonly + * + * @description + * One-based candidate page returned by the server. + * + * @access public + * @since unreleased + * + * @type {number} + */ + readonly page?: number; +} +/** + * Interface OrganizationParkState + * @interface + * + * @description + * Independent request states preserve partial availability without fabricated zero counts. + * + * @since unreleased + */ +interface OrganizationParkState { + /** + * Property scope + * @readonly + * + * @description + * Immutable query fingerprint preventing old customer or site results from being reused. + * + * @access public + * @since unreleased + * + * @type {string} + */ + readonly scope: string; + /** + * Property unavailableCallState + * @readonly + * + * @description + * Exact count of equipment currently declared under maintenance. + * + * @access public + * @since unreleased + * + * @type {CallState} + */ + readonly unavailableCallState: CallState; + /** + * Property controlsCallState + * @readonly + * + * @description + * Exact count of equipment in the due-soon or overdue server union. + * + * @access public + * @since unreleased + * + * @type {CallState} + */ + readonly controlsCallState: CallState; + /** + * Property anomaliesCallState + * @readonly + * + * @description + * Lifecycle of the unresolved anomaly projection; errors do not become zero. + * + * @access public + * @since unreleased + * + * @type {CallState} + */ + readonly anomaliesCallState: CallState; + /** + * Property sitesCallState + * @readonly + * + * @description + * Typed paginated root-site candidates for the customer selector. + * + * @access public + * @since unreleased + * + * @type {CallState>} + */ + readonly sitesCallState: CallState>; + /** + * Property siteCallState + * @readonly + * + * @description + * Separately hydrated retained root-site selection. + * + * @access public + * @since unreleased + * + * @type {CallState} + */ + readonly siteCallState: CallState; + /** + * Property siteQuery + * @readonly + * + * @description + * Committed root-site search and pagination scope. + * + * @access public + * @since unreleased + * + * @type {ParkSiteQuery | null} + */ + readonly siteQuery: ParkSiteQuery | null; +} +/** + * Constant initialState + * + * @description + * Unknown quantities remain null until an authorized server projection completes. + * + * @since unreleased + */ +const initialState: OrganizationParkState = { + scope: '', + unavailableCallState: idleCallState(), + controlsCallState: idleCallState(), + anomaliesCallState: idleCallState(), + sitesCallState: idleCallState(), + siteCallState: idleCallState(), + siteQuery: null, +}; + +/** + * Constant OrganizationParkStore + * + * @description + * Read-only park projection with scope-fenced requests and independent queue failures. + * + * @since unreleased + */ +export const OrganizationParkStore = signalStore( + withState(initialState), + withComputed((store) => ({ + siteOptions: computed(() => (store.sitesCallState().data?.member ?? []).map(toFacilityOption)), + selectedSite: computed(() => { + const site = store.siteCallState().data; + return site ? toFacilityOption(site) : null; + }), + sitePageCount: computed(() => + Math.max(1, Math.ceil((store.sitesCallState().data?.totalItems ?? 0) / 20)), + ), + })), + withMethods( + ( + store, + equipment = inject(EquipmentService), + park = inject(ParkService), + facilities = inject(FacilityService), + ) => ({ + /** + * @description + * Counts each queue across all pages and cancels every obsolete scoped read. + */ + load: rxMethod( + pipe( + switchMap((query) => { + if (!query) { + patchState(store, { + scope: '', + unavailableCallState: idleCallState(), + controlsCallState: idleCallState(), + anomaliesCallState: idleCallState(), + }); + return EMPTY; + } + const scope = JSON.stringify(query); + const sameScope = scope === store.scope(); + const params = { + ...(query.family ? { family: query.family } : {}), + ...(query.customerId ? { customerId: query.customerId } : {}), + }; + patchState(store, { + scope, + unavailableCallState: query.equipmentEnabled + ? pendingCallState(sameScope ? store.unavailableCallState().data : null) + : idleCallState(), + controlsCallState: query.equipmentEnabled + ? pendingCallState(sameScope ? store.controlsCallState().data : null) + : idleCallState(), + anomaliesCallState: query.anomaliesEnabled + ? pendingCallState(sameScope ? store.anomaliesCallState().data : null) + : idleCallState(), + }); + const unavailable = !query.equipmentEnabled + ? EMPTY + : (query.facilityId + ? equipment.summaryByFacility(query.organizationId, query.facilityId, true, { + params, + }) + : equipment.summary(query.organizationId, { params }) + ).pipe( + tapResponse({ + next: (summary) => + patchState(store, { + unavailableCallState: successCallState(summary.byStatus.under_maintenance), + }), + error: (error: unknown) => + patchState(store, { + unavailableCallState: errorCallState( + toStoreError(error), + store.unavailableCallState().data, + ), + }), + }), + ); + const dueOptions = { + itemsPerPage: 1, + page: 1, + params: { + ...params, + maintenanceDueStatus: 'due', + ...(query.facilityId ? { includeDescendants: true } : {}), + }, + }; + const controls = !query.equipmentEnabled + ? EMPTY + : (query.facilityId + ? equipment.listByFacility(query.organizationId, query.facilityId, dueOptions) + : equipment.list(query.organizationId, dueOptions) + ).pipe( + tapResponse({ + next: (collection) => + patchState(store, { + controlsCallState: successCallState(collection.totalItems), + }), + error: (error: unknown) => + patchState(store, { + controlsCallState: errorCallState( + toStoreError(error), + store.controlsCallState().data, + ), + }), + }), + ); + const anomalies = !query.anomaliesEnabled + ? EMPTY + : park + .anomaliesSummary(query.organizationId, { + params: { + ...params, + ...(query.facilityId ? { facilityId: query.facilityId } : {}), + }, + }) + .pipe( + tapResponse({ + next: (summary) => + patchState(store, { + anomaliesCallState: successCallState(summary.openAnomalies), + }), + error: (error: unknown) => + patchState(store, { + anomaliesCallState: errorCallState( + toStoreError(error), + store.anomaliesCallState().data, + ), + }), + }), + ); + return merge(unavailable, controls, anomalies); + }), + ), + ), + /** + * @description + * Reads only root sites belonging to the current customer. + */ + loadSites: rxMethod( + pipe( + switchMap((query) => { + patchState(store, { + siteQuery: query, + sitesCallState: query ? pendingCallState() : idleCallState(), + }); + if (!query) return EMPTY; + return facilities + .list(query.organizationId, { + rootsOnly: true, + includePath: true, + page: query.page ?? 1, + itemsPerPage: 20, + search: query.search, + ...(query.customerId ? { params: { customerId: query.customerId } } : {}), + }) + .pipe( + tapResponse({ + next: (collection) => + patchState(store, { sitesCallState: successCallState(collection) }), + error: (error: unknown) => + patchState(store, { sitesCallState: errorCallState(toStoreError(error)) }), + }), + ); + }), + ), + ), + /** + * @description + * Retains a selected site's label independently of its candidate page. + */ + readSite: rxMethod<{ readonly organizationId: string; readonly facilityId: string } | null>( + pipe( + switchMap((scope) => { + patchState(store, { siteCallState: scope ? pendingCallState() : idleCallState() }); + if (!scope) return EMPTY; + return facilities.get(scope.organizationId, scope.facilityId).pipe( + tapResponse({ + next: (site) => patchState(store, { siteCallState: successCallState(site) }), + error: (error: unknown) => + patchState(store, { siteCallState: errorCallState(toStoreError(error)) }), + }), + ); + }), + ), + ), + }), + ), +); + +/** + * Type OrganizationParkStoreType + * + * @description + * Component-provided store instance used by the park queues widget. + * + * @since unreleased + * + * @type + */ +export type OrganizationParkStoreType = InstanceType; diff --git a/src/app/features/organization/state/organization-park/testing/organization-park.store.spec.ts b/src/app/features/organization/state/organization-park/testing/organization-park.store.spec.ts new file mode 100644 index 000000000..b71cd5a14 --- /dev/null +++ b/src/app/features/organization/state/organization-park/testing/organization-park.store.spec.ts @@ -0,0 +1,158 @@ +import { TestBed } from '@angular/core/testing'; +import { of, Subject, throwError } from 'rxjs'; +import { ParkService } from '@features/organization/data-access'; +import { EquipmentService } from '@features/organization/features/equipments/data-access'; +import { FacilityService } from '@features/organization/features/facilities/data-access'; +import { OrganizationParkStore, type OrganizationParkStoreType } from '../organization-park.store'; + +describe('OrganizationParkStore', () => { + let store: OrganizationParkStoreType; + let equipment: { + summary: ReturnType; + summaryByFacility: ReturnType; + list: ReturnType; + listByFacility: ReturnType; + }; + let park: { anomaliesSummary: ReturnType }; + let facilities: { list: ReturnType; get: ReturnType }; + const summary = { + '@id': 'summary', + '@type': 'Summary', + scope: 'organization', + totalItems: 120, + byStatus: { under_maintenance: 3, decommissioned: 80, in_stock: 7, operational: 30 }, + needingAttentionCount: 83, + }; + beforeEach(() => { + equipment = { + summary: vi.fn().mockReturnValue(of(summary)), + summaryByFacility: vi.fn().mockReturnValue(of(summary)), + list: vi.fn().mockReturnValue(of({ member: [], totalItems: 27 })), + listByFacility: vi.fn().mockReturnValue(of({ member: [], totalItems: 27 })), + }; + park = { + anomaliesSummary: vi + .fn() + .mockReturnValue( + of({ openAnomalies: 8, bySeverity: { low: 1, medium: 2, high: 3, critical: 2 } }), + ), + }; + facilities = { + list: vi.fn().mockReturnValue(of({ member: [], totalItems: 44 })), + get: vi.fn(), + }; + TestBed.configureTestingModule({ + providers: [ + OrganizationParkStore, + { provide: EquipmentService, useValue: equipment }, + { provide: ParkService, useValue: park }, + { provide: FacilityService, useValue: facilities }, + ], + }); + store = TestBed.inject(OrganizationParkStore); + }); + it('counts unavailable equipment without counting retired history and shares the complete filter scope', () => { + store.load({ + organizationId: 'org', + family: 'fire', + customerId: 'customer', + facilityId: 'site', + equipmentEnabled: true, + anomaliesEnabled: true, + }); + expect(store.unavailableCallState().data).toBe(3); + expect(store.controlsCallState().data).toBe(27); + expect(store.anomaliesCallState().data).toBe(8); + expect(equipment.summaryByFacility).toHaveBeenCalledWith('org', 'site', true, { + params: { family: 'fire', customerId: 'customer' }, + }); + expect(equipment.listByFacility).toHaveBeenCalledWith('org', 'site', { + itemsPerPage: 1, + page: 1, + params: { + family: 'fire', + customerId: 'customer', + maintenanceDueStatus: 'due', + includeDescendants: true, + }, + }); + expect(park.anomaliesSummary).toHaveBeenCalledWith('org', { + params: { + family: 'fire', + customerId: 'customer', + facilityId: 'site', + }, + }); + }); + it('uses organization-wide scope without sending site-only traversal flags', () => { + store.load({ + organizationId: 'org', + family: 'fire', + equipmentEnabled: true, + anomaliesEnabled: true, + }); + expect(equipment.list).toHaveBeenCalledExactlyOnceWith('org', { + itemsPerPage: 1, + page: 1, + params: { family: 'fire', maintenanceDueStatus: 'due' }, + }); + expect(park.anomaliesSummary).toHaveBeenCalledExactlyOnceWith('org', { + params: { family: 'fire' }, + }); + }); + it('cancels all old client counts and never reuses old-scope results', () => { + const oldEquipment = new Subject(), + oldControls = new Subject(), + oldAnomalies = new Subject(); + equipment.summary.mockReturnValueOnce(oldEquipment); + equipment.list.mockReturnValueOnce(oldControls); + park.anomaliesSummary.mockReturnValueOnce(oldAnomalies); + store.load({ + organizationId: 'org', + family: 'fire', + customerId: 'old', + equipmentEnabled: true, + anomaliesEnabled: true, + }); + store.load({ + organizationId: 'org', + family: 'fire', + customerId: 'new', + equipmentEnabled: true, + anomaliesEnabled: true, + }); + expect(oldEquipment.observed).toBe(false); + expect(oldControls.observed).toBe(false); + expect(oldAnomalies.observed).toBe(false); + expect(store.unavailableCallState().data).toBe(3); + }); + it('keeps a failed count unknown while other queues complete and suppresses denied reads', () => { + park.anomaliesSummary.mockReturnValue( + throwError(() => ({ status: 403, title: 'Forbidden', detail: 'Missing read permission' })), + ); + store.load({ organizationId: 'org', equipmentEnabled: true, anomaliesEnabled: true }); + expect(store.anomaliesCallState().status).toBe('error'); + expect(store.anomaliesCallState().data).toBeNull(); + expect(store.controlsCallState().data).toBe(27); + equipment.summary.mockClear(); + equipment.list.mockClear(); + park.anomaliesSummary.mockClear(); + store.load({ organizationId: 'org', equipmentEnabled: false, anomaliesEnabled: false }); + expect(equipment.summary).not.toHaveBeenCalled(); + expect(equipment.list).not.toHaveBeenCalled(); + expect(park.anomaliesSummary).not.toHaveBeenCalled(); + expect(store.controlsCallState().data).toBeNull(); + }); + it('searches and pages only the current customer root sites', () => { + store.loadSites({ organizationId: 'org', customerId: 'customer', search: 'north', page: 2 }); + expect(facilities.list).toHaveBeenCalledWith('org', { + rootsOnly: true, + includePath: true, + page: 2, + itemsPerPage: 20, + search: 'north', + params: { customerId: 'customer' }, + }); + expect(store.sitePageCount()).toBe(3); + }); +}); diff --git a/src/app/features/organization/ui/components/index.ts b/src/app/features/organization/ui/components/index.ts index 8692e4359..40535fcf8 100644 --- a/src/app/features/organization/ui/components/index.ts +++ b/src/app/features/organization/ui/components/index.ts @@ -20,3 +20,5 @@ export { OrganizationDashboardRisk } from './organization-dashboard-risk/organiz export { OrganizationDashboardAlerts } from './organization-dashboard-alerts/organization-dashboard-alerts.component'; export type { OrganizationDashboardAlertRow } from './organization-dashboard-alerts/models/organization-dashboard-alert-row.interface'; export { OrganizationDashboardRecent } from './organization-dashboard-recent/organization-dashboard-recent.component'; + +export { OrganizationParkQueues } from './organization-park-queues/organization-park-queues.component'; diff --git a/src/app/features/organization/ui/components/organization-mobile-navigation/testing/organization-mobile-navigation.component.spec.ts b/src/app/features/organization/ui/components/organization-mobile-navigation/testing/organization-mobile-navigation.component.spec.ts index 353a5b54e..dfba2c840 100644 --- a/src/app/features/organization/ui/components/organization-mobile-navigation/testing/organization-mobile-navigation.component.spec.ts +++ b/src/app/features/organization/ui/components/organization-mobile-navigation/testing/organization-mobile-navigation.component.spec.ts @@ -36,9 +36,9 @@ describe('OrganizationMobileNavigation', () => { const root = fixture.nativeElement as HTMLElement; const links = Array.from(root.querySelectorAll('a')); expect(links.map((link) => link.dataset['destination'])).toEqual([ - 'dashboard', - 'interventions', 'assets', + 'interventions', + 'dashboard', 'messages', 'more', ]); diff --git a/src/app/features/organization/ui/components/organization-park-queues/organization-park-queues.component.html b/src/app/features/organization/ui/components/organization-park-queues/organization-park-queues.component.html new file mode 100644 index 000000000..b85bb0c6a --- /dev/null +++ b/src/app/features/organization/ui/components/organization-park-queues/organization-park-queues.component.html @@ -0,0 +1,124 @@ +@if (canReadEquipment()) { +
+

Park actions

+
+ + + + @if (canReadCustomers()) { +
+ +
+ } + @if (canReadSites()) { +
+ +
+ } +
+
+ @if (canReadEquipment()) { + + + } + @if (canReadAnomalies()) { + + } +
+ @if ( + store.unavailableCallState().error || + store.controlsCallState().error || + store.anomaliesCallState().error + ) { +
+

Some park counts could not be loaded

+

+ {{ + store.unavailableCallState().error?.message ?? + store.controlsCallState().error?.message ?? + store.anomaliesCallState().error?.message + }} +

+ +
+ } +
+} diff --git a/src/app/features/organization/ui/components/organization-park-queues/organization-park-queues.component.ts b/src/app/features/organization/ui/components/organization-park-queues/organization-park-queues.component.ts new file mode 100644 index 000000000..bb56e2c4c --- /dev/null +++ b/src/app/features/organization/ui/components/organization-park-queues/organization-park-queues.component.ts @@ -0,0 +1,545 @@ +import { isPlatformBrowser } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + PLATFORM_ID, + computed, + effect, + inject, + input, + signal, + untracked, + type InputSignal, + type Signal, + type WritableSignal, +} from '@angular/core'; +import { provideIcons } from '@ng-icons/core'; +import { lucideWrench, lucideClipboardCheck, lucideTriangleAlert } from '@ng-icons/lucide'; +import type { CallState } from '@core/request-state'; +import { OrganizationPermissionService } from '@features/organization/access'; +import { CustomerPicker } from '@features/organization/features/customers/ui/components'; +import { FacilityOptionPicker } from '@features/organization/features/facilities/ui/components'; +import { ORGANIZATION_PERMISSION } from '@features/organization/models'; +import { + OrganizationParkStore, + type OrganizationParkStoreType, +} from '@features/organization/state/organization-park'; +import { HlmAlertImports } from '@shared/ui/alert'; +import { HlmButton } from '@shared/ui/button'; +import { HlmFieldImports } from '@shared/ui/field'; +import { HlmToggleGroupImports } from '@shared/ui/toggle-group'; +import { HlmLarge } from '@shared/ui/typography'; +import { StatTile } from '../stat-tile'; + +/** + * Class OrganizationParkQueues + * @class OrganizationParkQueues + * + * @description + * Server-scoped queues lead the dashboard without altering its organization-wide activity charts. + * + * @since unreleased + */ +@Component({ + selector: 'app-organization-park-queues', + templateUrl: './organization-park-queues.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + OrganizationParkStore, + provideIcons({ lucideWrench, lucideClipboardCheck, lucideTriangleAlert }), + ], + imports: [ + CustomerPicker, + FacilityOptionPicker, + StatTile, + HlmButton, + HlmLarge, + ...HlmFieldImports, + ...HlmAlertImports, + ...HlmToggleGroupImports, + ], +}) +export class OrganizationParkQueues { + //#region Properties + /** + * Property organizationId + * @readonly + * + * @description + * Organization selected by the owning page. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly organizationId: InputSignal = input.required(); + /** + * Property permissions + * @readonly + * + * @description + * Equipment projections have their own grant, separate from findings. + * + * @access protected + * @since unreleased + * + * @type {OrganizationPermissionService} + */ + protected readonly permissions: OrganizationPermissionService = inject( + OrganizationPermissionService, + ); + /** + * Property store + * @readonly + * + * @description + * Component-scoped count and site-picker state. + * + * @access protected + * @since unreleased + * + * @type {OrganizationParkStoreType} + */ + protected readonly store: OrganizationParkStoreType = inject(OrganizationParkStore); + /** + * Property platformId + * @readonly + * + * @description + * Secondary scoped queues do not initiate authenticated reads during SSR. + * + * @access private + * @since unreleased + * + * @type {object} + */ + private readonly platformId: object = inject(PLATFORM_ID); + /** + * Property family + * @readonly + * + * @description + * Fire-only is the default product scope. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal<'fire' | 'all'>} + */ + protected readonly family: WritableSignal<'fire' | 'all'> = signal('fire'); + /** + * Property customerId + * @readonly + * + * @description + * Optional customer context; empty means all permitted customers. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal} + */ + protected readonly customerId: WritableSignal = signal(''); + /** + * Property facilityId + * @readonly + * + * @description + * Selected root site and its descendants. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal} + */ + protected readonly facilityId: WritableSignal = signal(''); + /** + * Property canReadCustomers + * @readonly + * + * @description + * Directory choices are available only with their read grants. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly canReadCustomers: Signal = computed(() => + this.permissions.hasPermission(ORGANIZATION_PERMISSION.CUSTOMERS_READ), + ); + /** + * Property canReadSites + * @readonly + * + * @description + * Root sites are independently gated. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly canReadSites: Signal = computed(() => + this.permissions.hasPermission(ORGANIZATION_PERMISSION.FACILITIES_READ), + ); + /** + * Property canReadEquipment + * @readonly + * + * @description + * Equipment queue counts require their owning read grant. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly canReadEquipment: Signal = computed(() => + this.permissions.hasPermission(ORGANIZATION_PERMISSION.EQUIPMENT_READ), + ); + /** + * Property canReadAnomalies + * @readonly + * + * @description + * Finding queue counts require inspection and equipment read grants. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly canReadAnomalies: Signal = computed( + () => + this.permissions.hasPermission(ORGANIZATION_PERMISSION.INSPECTION_READ) && + this.permissions.hasPermission(ORGANIZATION_PERMISSION.EQUIPMENT_READ), + ); + /** + * Property sitePickerCallState + * @readonly + * + * @description + * Candidate control consumes status and error, while the store retains the typed collection. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly sitePickerCallState: Signal = computed(() => ({ + ...this.store.sitesCallState(), + data: null, + })); + /** + * Property destinationParams + * @readonly + * + * @description + * Exact destination query shared with the counts. + * + * @access protected + * @since unreleased + * + * @type {Signal>>} + */ + protected readonly destinationParams: Signal>> = computed(() => ({ + axis: this.facilityId() ? 'site' : 'everything', + family: this.family(), + ...(this.customerId() ? { customerId: this.customerId() } : {}), + ...(this.facilityId() ? { facility: this.facilityId(), equipmentScope: 'subtree' } : {}), + })); + /** + * Property unavailableParams + * @readonly + * + * @description + * Each queue link preserves exactly the filters used by its server count. + * + * @access protected + * @since unreleased + * + * @type {Signal>>} + */ + protected readonly unavailableParams: Signal>> = computed(() => ({ + ...this.destinationParams(), + queue: 'unavailable', + })); + /** + * Property controlsParams + * @readonly + * + * @description + * Due-soon and overdue are one server-filtered destination. + * + * @access protected + * @since unreleased + * + * @type {Signal>>} + */ + protected readonly controlsParams: Signal>> = computed(() => ({ + ...this.destinationParams(), + queue: 'controls', + })); + /** + * Property anomaliesParams + * @readonly + * + * @description + * The unresolved anomaly list uses the same customer and root-site scope. + * + * @access protected + * @since unreleased + * + * @type {Signal>>} + */ + protected readonly anomaliesParams: Signal>> = computed(() => ({ + ...this.destinationParams(), + queue: 'anomalies', + })); + /** + * Property parkRoute + * @readonly + * + * @description + * Park navigation requires the site read grant enforced by the assets route. + * Counts remain readable without offering an unauthorized destination. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly parkRoute: Signal = computed(() => + this.canReadSites() ? ['/organizations', this.organizationId(), 'assets'] : null, + ); + /** + * Property unavailable + * @readonly + * + * @description + * Unknown, denied or failed reads never become a zero. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly unavailable: Signal = computed( + () => this.store.unavailableCallState().data ?? '—', + ); + /** + * Property controls + * @readonly + * + * @description + * Count is the server's single due-soon and overdue union. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly controls: Signal = computed( + () => this.store.controlsCallState().data ?? '—', + ); + /** + * Property anomalies + * @readonly + * + * @description + * Count is open and in-progress anomalies, independent of inspection completion. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly anomalies: Signal = computed( + () => this.store.anomaliesCallState().data ?? '—', + ); + /** + * Property allSites + * @readonly + * + * @description + * Site picker empty choice is a scope filter, not a parent assignment. + * + * @access protected + * @since unreleased + * + * @type {string} + */ + protected readonly allSites: string = $localize`:@@park.context.allSites:All sites`; + //#endregion + + //#region Constructor + /** + * Constructor + * @constructor + * + * @description + * Resets per-organization filter drafts and cancels obsolete scoped requests. + * + * @access public + * @since unreleased + */ + public constructor() { + effect(() => { + this.organizationId(); + untracked(() => { + this.customerId.set(''); + this.facilityId.set(''); + this.family.set('fire'); + }); + }); + effect(() => { + const organizationId = this.organizationId(), + family = this.family(), + customerId = this.customerId(), + facilityId = this.facilityId(); + const equipmentEnabled = this.canReadEquipment(), + anomaliesEnabled = this.canReadAnomalies(); + untracked(() => + this.store.load( + isPlatformBrowser(this.platformId) + ? { + organizationId, + ...(family === 'fire' ? { family } : {}), + ...(customerId ? { customerId } : {}), + ...(facilityId ? { facilityId } : {}), + equipmentEnabled, + anomaliesEnabled, + } + : null, + ), + ); + }); + effect(() => { + const organizationId = this.organizationId(), + customerId = this.customerId(), + enabled = this.canReadSites() && this.canReadEquipment(); + untracked(() => + this.store.loadSites( + enabled && isPlatformBrowser(this.platformId) + ? { organizationId, ...(customerId ? { customerId } : {}) } + : null, + ), + ); + }); + effect(() => { + const organizationId = this.organizationId(), + facilityId = this.facilityId(), + enabled = this.canReadSites(); + untracked(() => + this.store.readSite( + enabled && facilityId && isPlatformBrowser(this.platformId) + ? { organizationId, facilityId } + : null, + ), + ); + }); + } + //#endregion + + //#region Methods + /** + * Method familyChanged + * @method + * + * @description + * Toggles the complete park without rewriting equipment type classifications. + * + * @access protected + * @since unreleased + * + * @param {unknown} value - Value supplied by the owning park workflow. + * + * @returns {void} No return value. + */ + protected familyChanged(value: unknown): void { + if (value === 'fire' || value === 'all') this.family.set(value); + } + /** + * Method customerChanged + * @method + * + * @description + * Clears a selected site before changing customer authority. + * + * @access protected + * @since unreleased + * + * @param {string} value - Value supplied by the owning park workflow. + * + * @returns {void} No return value. + */ + protected customerChanged(value: string): void { + this.customerId.set(value); + this.facilityId.set(''); + } + /** + * Method searchSites + * @method + * + * @description + * Searches root-site server pages in the retained customer context. + * + * @access protected + * @since unreleased + * + * @param {string} search - Server search over root-site names and paths. + * + * @returns {void} No return value. + */ + protected searchSites(search: string): void { + this.store.loadSites({ + organizationId: this.organizationId(), + ...(this.customerId() ? { customerId: this.customerId() } : {}), + search, + page: 1, + }); + } + /** + * Method sitePage + * @method + * + * @description + * Pages and retries the same committed candidate search. + * + * @access protected + * @since unreleased + * + * @param {number} page - One-based candidate page returned by the server. + * + * @returns {void} No return value. + */ + protected sitePage(page: number): void { + const query = this.store.siteQuery(); + if (query) this.store.loadSites({ ...query, page }); + } + /** + * Method retry + * @method + * + * @description + * Retries the same complete three-queue scope. + * + * @access protected + * @since unreleased + * + * @returns {void} No return value. + */ + protected retry(): void { + this.store.load({ + organizationId: this.organizationId(), + ...(this.family() === 'fire' ? { family: 'fire' } : {}), + ...(this.customerId() ? { customerId: this.customerId() } : {}), + ...(this.facilityId() ? { facilityId: this.facilityId() } : {}), + equipmentEnabled: this.canReadEquipment(), + anomaliesEnabled: this.canReadAnomalies(), + }); + } + //#endregion +} diff --git a/src/app/features/organization/ui/components/organization-park-queues/testing/organization-park-queues.component.spec.ts b/src/app/features/organization/ui/components/organization-park-queues/testing/organization-park-queues.component.spec.ts new file mode 100644 index 000000000..494ebcdc0 --- /dev/null +++ b/src/app/features/organization/ui/components/organization-park-queues/testing/organization-park-queues.component.spec.ts @@ -0,0 +1,149 @@ +import { PLATFORM_ID, signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import { of } from 'rxjs'; +import { INTERACTION_CAPABILITIES_PORT } from '@core/interaction-capabilities'; +import { OrganizationPermissionService } from '@features/organization/access'; +import { ParkService } from '@features/organization/data-access'; +import { CustomerService } from '@features/organization/features/customers/data-access'; +import { EquipmentService } from '@features/organization/features/equipments/data-access'; +import { FacilityService } from '@features/organization/features/facilities/data-access'; +import { ORGANIZATION_PERMISSION } from '@features/organization/models'; +import { OrganizationParkQueues } from '../organization-park-queues.component'; + +const setup = ( + platform = 'browser', + grants: readonly string[] = [ + ORGANIZATION_PERMISSION.EQUIPMENT_READ, + ORGANIZATION_PERMISSION.INSPECTION_READ, + ORGANIZATION_PERMISSION.FACILITIES_READ, + ], +) => { + const permissions = signal(grants); + const equipment = { + summary: vi.fn().mockReturnValue(of({ byStatus: { under_maintenance: 2 } })), + summaryByFacility: vi.fn().mockReturnValue(of({ byStatus: { under_maintenance: 2 } })), + list: vi.fn().mockReturnValue(of({ totalItems: 5, member: [] })), + listByFacility: vi.fn().mockReturnValue(of({ totalItems: 5, member: [] })), + }; + const park = { + anomaliesSummary: vi.fn().mockReturnValue(of({ openAnomalies: 4, bySeverity: {} })), + }; + const facilities = { + list: vi.fn().mockReturnValue(of({ member: [], totalItems: 0 })), + get: vi.fn().mockReturnValue(of({ id: 'site', name: 'North campus', type: 'site', path: [] })), + }; + TestBed.configureTestingModule({ + imports: [OrganizationParkQueues], + providers: [ + provideRouter([]), + { provide: PLATFORM_ID, useValue: platform }, + { + provide: INTERACTION_CAPABILITIES_PORT, + useValue: { isMobileInteractionMode: signal(false), interactionMode: () => 'desktop' }, + }, + { + provide: OrganizationPermissionService, + useValue: { hasPermission: (permission: string) => permissions().includes(permission) }, + }, + { provide: EquipmentService, useValue: equipment }, + { provide: ParkService, useValue: park }, + { provide: FacilityService, useValue: facilities }, + { + provide: CustomerService, + useValue: { + list: vi.fn().mockReturnValue(of({ member: [], totalItems: 0 })), + get: vi.fn(), + }, + }, + ], + }); + const fixture = TestBed.createComponent(OrganizationParkQueues); + fixture.componentRef.setInput('organizationId', 'org'); + return { fixture, equipment, park, facilities, permissions }; +}; + +describe('OrganizationParkQueues', () => { + beforeAll(() => { + globalThis.ResizeObserver ??= class { + observe(): void {} + unobserve(): void {} + disconnect(): void {} + } as unknown as typeof ResizeObserver; + }); + it('loads three exact counts and routes each action with the same client, site and family', async () => { + const { fixture, equipment } = setup(); + await fixture.whenStable(); + expect(fixture.nativeElement.querySelectorAll('app-stat-tile')).toHaveLength(3); + fixture.componentInstance['customerChanged']('customer'); + fixture.componentInstance['facilityId'].set('site'); + fixture.componentInstance['familyChanged']('all'); + await fixture.whenStable(); + expect(equipment.summaryByFacility).toHaveBeenLastCalledWith('org', 'site', true, { + params: { customerId: 'customer' }, + }); + const links = Array.from( + fixture.nativeElement.querySelectorAll('app-stat-tile a') as NodeListOf, + ); + const controls = links.find((link) => link.textContent?.includes('Controls to prepare')); + expect(controls).toBeDefined(); + const url = new URL(controls?.href ?? '', 'https://app.test'); + expect(url.searchParams.get('family')).toBe('all'); + expect(url.searchParams.get('customerId')).toBe('customer'); + expect(url.searchParams.get('facility')).toBe('site'); + expect(url.searchParams.get('queue')).toBe('controls'); + }); + it('does not read queue or site data during SSR', async () => { + const { fixture, equipment, park, facilities } = setup('server'); + await fixture.whenStable(); + expect(equipment.summary).not.toHaveBeenCalled(); + expect(park.anomaliesSummary).not.toHaveBeenCalled(); + expect(facilities.list).not.toHaveBeenCalled(); + }); + it('requires both inspection and equipment reads for the anomaly queue', async () => { + const { fixture, equipment, park } = setup('browser', [ + ORGANIZATION_PERMISSION.INSPECTION_READ, + ]); + await fixture.whenStable(); + expect(equipment.summary).not.toHaveBeenCalled(); + expect(park.anomaliesSummary).not.toHaveBeenCalled(); + expect(fixture.nativeElement.querySelectorAll('app-stat-tile')).toHaveLength(0); + }); + + it('renders authorized counts without dead-route links for an equipment and dashboard reader', async () => { + const { fixture, equipment, facilities } = setup('browser', [ + ORGANIZATION_PERMISSION.EQUIPMENT_READ, + ORGANIZATION_PERMISSION.DASHBOARD_READ, + ]); + await fixture.whenStable(); + expect(equipment.summary).toHaveBeenCalled(); + expect(fixture.nativeElement.querySelectorAll('app-stat-tile')).toHaveLength(2); + expect(fixture.nativeElement.querySelectorAll('app-stat-tile a')).toHaveLength(0); + expect(fixture.nativeElement.textContent).toContain('Unavailable equipment'); + expect(fixture.nativeElement.textContent).toContain('Controls to prepare'); + expect(fixture.componentInstance['unavailable']()).toBe(2); + expect(fixture.componentInstance['controls']()).toBe(5); + expect(facilities.list).not.toHaveBeenCalled(); + expect(facilities.get).not.toHaveBeenCalled(); + }); + + it('removes every park link and selected-site read after the site grant is revoked', async () => { + const { fixture, permissions } = setup(); + await fixture.whenStable(); + expect(fixture.nativeElement.querySelectorAll('app-stat-tile a')).toHaveLength(3); + fixture.componentInstance['facilityId'].set('site'); + await fixture.whenStable(); + const siteReads = fixture.componentInstance['store'].siteCallState(); + expect(siteReads.status).toBe('success'); + permissions.set([ + ORGANIZATION_PERMISSION.EQUIPMENT_READ, + ORGANIZATION_PERMISSION.INSPECTION_READ, + ORGANIZATION_PERMISSION.DASHBOARD_READ, + ]); + await fixture.whenStable(); + expect(fixture.nativeElement.querySelectorAll('app-stat-tile')).toHaveLength(3); + expect(fixture.nativeElement.querySelectorAll('app-stat-tile a')).toHaveLength(0); + expect(fixture.componentInstance['store'].siteCallState().status).toBe('idle'); + expect(fixture.componentInstance['controls']()).toBe(5); + }); +}); diff --git a/src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html b/src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html index 80678782f..3e522ebd0 100644 --- a/src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html +++ b/src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html @@ -1,4 +1,52 @@
+ @if (axis() !== 'compliance') { +
+ + + + + @if (canReadCustomers()) { +
+ + +
+ } + @if (queue()) { + + } +
+ } Try again @@ -490,69 +538,230 @@

{{ facility.name }}

- @if (canReadEquipment()) { -
-
- @if (axis() === 'site' && selectedFacilityId() !== null) { -

- Equipment - @if (!pane.isLoadingEquipment()) { - {{ - pane.equipmentTotal() - }} - } -

+ @if (queue() === 'anomalies') { + @if (canReadEquipment() && canReadInspections()) { +
+

Anomalies to address

+ @if (pane.anomaliesCallState().status === 'success' && pane.anomalies().length === 0) { +
+
+ +

+ No unresolved anomalies in this park scope +

+
+
} @else { -

- Equipment - @if (!pane.isLoadingEquipment()) { - {{ - pane.equipmentTotal() - }} - } -

+ } - @if (axis() === 'site' && selectedFacilityId() !== null) { - - @if (labelsBusy()) { - +
+ } + } @else { + @if (canReadEquipment()) { +
+
+ @if (axis() === 'site' && selectedFacilityId() !== null) { +

+ Equipment + @if (!pane.isLoadingEquipment()) { + {{ + pane.equipmentTotal() + }} + } +

+ } @else { +

+ Equipment + @if (!pane.isLoadingEquipment()) { + {{ + pane.equipmentTotal() + }} + } +

+ } + @if (axis() === 'site' && selectedFacilityId() !== null) { + + @if (labelsBusy()) { + + Preparing the label sheet… + + } } - } -
+
- @if (pane.isLoadingEquipment()) { -
- Loading resources… + @if (pane.isLoadingEquipment()) { +
+ Loading resources… + @if (isMobileInteractionMode()) { + + } @else { + + } +
+ } @else if (pane.hasEquipmentError()) { +
+
+
+
+

Couldn't load equipment.

+
+
+ +
+
+ } @else if (!pane.isLoadingEquipment() && pane.equipment().length === 0) { +
+
+ @if ( + pane.equipmentListCallState().status === 'success' && pane.equipmentTotal() === 0 + ) { + + } @else { +
+
+ } +

No equipment in view.

+

+ No equipment is assigned to this scope yet. +

+
+ @if (canCreateEquipment()) { + + } +
+ } @else { @if (isMobileInteractionMode()) { - +
+ @for (item of pane.equipment(); track item.id) { +
+
+ + {{ equipmentTitleOf(item) }} + + @if (equipmentSecondaryLineOf(item); as secondary) { +

{{ secondary }}

+ } +

+ @if (item.facilityName; as facilityName) { + {{ facilityName }} + @if (item.locationLabel) { + · {{ item.locationLabel }} + } + } @else if (item.locationLabel) { + {{ item.locationLabel }} + } @else { + + } +

+
+
+ + +
+
+ } +
} @else { - } -
- } @else if (pane.hasEquipmentError()) { -
-
-
-
-

Couldn't load equipment.

-
-
- -
-
- } @else if (!pane.isLoadingEquipment() && pane.equipment().length === 0) { -
-
- @if ( - pane.equipmentListCallState().status === 'success' && pane.equipmentTotal() === 0 - ) { - + } + @if (pane.equipmentTotal() > 50) { + + } +
+ } + + @if (canReadInspections()) { +
+ @if (axis() === 'site' && selectedFacilityId() !== null) { +

+ Inspections + @if (!pane.isLoadingInspections()) { + {{ + pane.inspectionTotal() + }} + } +

+ } @else { +

+ Inspections + @if (!pane.isLoadingInspections()) { + {{ + pane.inspectionTotal() + }} + } +

+ } + + @if (pane.isLoadingInspections()) { +
+ Loading resources… + @if (isMobileInteractionMode()) { + } @else { -
-
- @if (canCreateEquipment()) { + } @else if (pane.hasInspectionsError()) { +
+
+
+
+

+ Couldn't load inspections. +

+
- - + Try again +
- } -
- } @else { - @if (isMobileInteractionMode()) { -
- @for (item of pane.equipment(); track item.id) { -
-
- - {{ typeLabelOf(item.type) }} - - @if (equipmentSecondaryLineOf(item); as secondary) { -

{{ secondary }}

- } -

- @if (item.facilityName; as facilityName) { - {{ facilityName }} - @if (item.locationLabel) { - · {{ item.locationLabel }} - } - } @else if (item.locationLabel) { - {{ item.locationLabel }} - } @else { - - } -

-
-
- - +
+ } @else if (!pane.isLoadingInspections() && pane.inspections().length === 0) { +
+
+ @if ( + pane.inspectionListCallState().status === 'success' && + pane.inspectionTotal() === 0 + ) { + + } @else { +
+
-
- } + } +

No inspections in view.

+

+ No inspection has been recorded for this scope yet. +

+
} @else { -
- - - - - - - - - - - @for (item of pane.equipment(); track item.id) { - - - - - - - } - -
- Equipment - - Facility - - Status - - Maintenance -
- - {{ typeLabelOf(item.type) }} - - @if (equipmentSecondaryLineOf(item); as secondary) { - {{ secondary }} - } - - @if (item.facilityName; as facilityName) { - {{ facilityName }} - @if (item.locationLabel) { - {{ - item.locationLabel - }} - } - } @else if (item.locationLabel) { - {{ item.locationLabel }} - } @else { - - } - - - - -
-
- } - } - @if (pane.equipmentTotal() > 50) { - - } -
- } - - @if (canReadInspections()) { -
- @if (axis() === 'site' && selectedFacilityId() !== null) { -

- Inspections - @if (!pane.isLoadingInspections()) { - {{ - pane.inspectionTotal() - }} - } -

- } @else { -

- Inspections - @if (!pane.isLoadingInspections()) { - {{ - pane.inspectionTotal() - }} - } -

- } - - @if (pane.isLoadingInspections()) { -
- Loading resources… @if (isMobileInteractionMode()) { - +
+ @for (item of pane.inspections(); track item.id) { +
+ +
+ + + + + @if (item.inspector?.avatarUrl; as avatar) { + + } + {{ + inspectorAvatarFallback(item.inspector) + }} + + {{ + item.inspector?.displayName ?? notSpecifiedLabel + }} + + @if (item.nonConformitiesCount > 0) { + + + } +
+
+ } +
} @else { - } -
- } @else if (pane.hasInspectionsError()) { -
-
-
-
-

Couldn't load inspections.

-
-
- -
-
- } @else if (!pane.isLoadingInspections() && pane.inspections().length === 0) { -
-
- @if ( - pane.inspectionListCallState().status === 'success' && pane.inspectionTotal() === 0 - ) { - - } @else { -
-
- } -

No inspections in view.

-

- No inspection has been recorded for this scope yet. -

-
-
- } @else { - @if (isMobileInteractionMode()) { -
- @for (item of pane.inspections(); track item.id) { -
- -
- - - - - @if (item.inspector?.avatarUrl; as avatar) { - - } - {{ - inspectorAvatarFallback(item.inspector) - }} - - {{ - item.inspector?.displayName ?? notSpecifiedLabel - }} - - @if (item.nonConformitiesCount > 0) { - - - } -
-
- } -
- } @else { -
- - - - - - - - - - - - @for (item of pane.inspections(); track item.id) { - - - - - - - - } - -
- Performed - - Inspector - - Result - - Status - - Non-conformities -
- - {{ item.performedAt | appOrgDate: 'datetime' : regionalFormatting() }} - - - - - @if (item.inspector?.avatarUrl; as avatar) { - - } - {{ - inspectorAvatarFallback(item.inspector) - }} - - {{ - item.inspector?.displayName ?? notSpecifiedLabel - }} - - - - - - - @if (item.nonConformitiesCount > 0) { - - - } @else { - {{ item.nonConformitiesCount }} - } -
-
} - } - @if (pane.inspectionTotal() > 50) { - - } -
+ @if (pane.inspectionTotal() > 50) { + + } + + } }
diff --git a/src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.ts b/src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.ts index c9c8f9504..d6adfaac6 100644 --- a/src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.ts +++ b/src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.ts @@ -53,13 +53,15 @@ import { PageTabsService, registerPageTabs } from '@core/page-tabs'; import type { CallState, StoreError } from '@core/request-state'; import { OrganizationPermissionService } from '@features/organization/access'; import { COMPLIANCE_BUCKET_TAG_ICON_CLASS } from '@features/organization/constants'; -import { EQUIPMENT_TYPE_OPTIONS } from '@features/organization/features/equipments'; +import { CustomerPicker } from '@features/organization/features/customers/ui/components'; +import { + EquipmentTypeCatalogStore, + buildEquipmentTitle, + type EquipmentTypeCatalogStoreType, +} from '@features/organization/features/equipments'; import { EquipmentService } from '@features/organization/features/equipments/data-access'; -import type { - EquipmentOutput, - EquipmentType, -} from '@features/organization/features/equipments/models'; -import { EquipmentStatusTag } from '@features/organization/features/equipments/ui/components/equipment-status-tag'; +import type { EquipmentOutput } from '@features/organization/features/equipments/models'; +import { EquipmentStatusTag } from '@features/organization/features/equipments/ui/components'; import type { FacilityOption, FacilityMoveRequest, @@ -78,6 +80,7 @@ import { } from '@features/organization/features/facilities/utils'; import type { InspectorOutput } from '@features/organization/features/inspections/models'; import { InspectionStatusTag } from '@features/organization/features/inspections/ui/components/inspection-status-tag'; +import { NonConformityList } from '@features/organization/features/inspections/ui/dataviews/non-conformity-list'; import { ORGANIZATION_PERMISSION, resolveComplianceBucketTag, @@ -124,6 +127,7 @@ import { HlmSeparator } from '@shared/ui/separator'; import { HlmSkeleton } from '@shared/ui/skeleton'; import { HlmTableImports } from '@shared/ui/table'; import { HlmTabsImports } from '@shared/ui/tabs'; +import { HlmToggleGroupImports } from '@shared/ui/toggle-group'; import { HlmTooltip } from '@shared/ui/tooltip'; import { HlmLarge } from '@shared/ui/typography'; import { resolveComplianceStatusTag } from './models/compliance-status-tag/compliance-status-tag.util'; @@ -178,6 +182,9 @@ type OrganizationAssetsAxis = 'site' | 'everything' | 'compliance'; @Component({ selector: 'app-organization-assets-page', imports: [ + CustomerPicker, + NonConformityList, + ...HlmToggleGroupImports, CollectionPagination, ResourceIllustration, StateIllustration, @@ -210,6 +217,7 @@ type OrganizationAssetsAxis = 'site' | 'everything' | 'compliance'; ...HlmTabsImports, ], providers: [ + EquipmentTypeCatalogStore, FacilityOptionsStore, provideIcons({ lucideCircleAlert, @@ -1177,8 +1185,11 @@ export class OrganizationAssetsPage { * * @type {Signal} */ - protected readonly canReadInspections: Signal = computed(() => - this.permissions.hasPermission(ORGANIZATION_PERMISSION.INSPECTION_READ), + protected readonly canReadInspections: Signal = computed( + () => + this.permissions.hasPermission(ORGANIZATION_PERMISSION.INSPECTION_READ) && + ((this.family() === 'all' && !this.customerId()) || + this.permissions.hasPermission(ORGANIZATION_PERMISSION.EQUIPMENT_READ)), ); /** @@ -1221,6 +1232,166 @@ export class OrganizationAssetsPage { ); //#endregion + //#region Properties + /** + * Property familyParam + * @readonly + * + * @description + * Directory's fire-only default is a server family filter, never a list of hardcoded types. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly familyParam: InputSignal = input( + undefined, + { alias: 'family' }, + ); + /** + * Property customerParam + * @readonly + * + * @description + * Customer context restored from a scoped dashboard or shared Parc link. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly customerParam: InputSignal = input( + undefined, + { alias: 'customerId' }, + ); + /** + * Property queueParam + * @readonly + * + * @description + * Action queue restored from the dashboard. + * + * @access public + * @since unreleased + * + * @type {InputSignal} + */ + public readonly queueParam: InputSignal = input( + undefined, + { alias: 'queue' }, + ); + /** + * Property family + * @readonly + * + * @description + * Family selected for equipment and control projections. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal<'fire' | 'all'>} + */ + protected readonly family: WritableSignal<'fire' | 'all'> = signal('fire'); + /** + * Property customerId + * @readonly + * + * @description + * Optional customer context; empty means the organization's complete park. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal} + */ + protected readonly customerId: WritableSignal = signal(''); + /** + * Property queue + * @readonly + * + * @description + * Scoped action queue; controls uses the server's due union. + * + * @access protected + * @since unreleased + * + * @type {WritableSignal} + */ + protected readonly queue: WritableSignal = signal(''); + /** + * Property catalog + * @readonly + * + * @description + * Server-owned equipment labels include custom and archived catalog types. + * + * @access protected + * @since unreleased + * + * @type {EquipmentTypeCatalogStoreType} + */ + protected readonly catalog: EquipmentTypeCatalogStoreType = inject(EquipmentTypeCatalogStore); + /** + * Property canReadCustomers + * @readonly + * + * @description + * Directory access is independent of equipment and facility permissions. + * + * @access protected + * @since unreleased + * + * @type {Signal} + */ + protected readonly canReadCustomers: Signal = computed(() => + this.permissions.hasPermission(ORGANIZATION_PERMISSION.CUSTOMERS_READ), + ); + /** + * Property parkFilters + * @readonly + * + * @description + * Reusable immutable filter scope for reads, retries and pagination. + * + * @access protected + * @since unreleased + * + * @type {Signal<{ readonly family?: string; readonly customerId?: string }>} + */ + protected readonly parkFilters: Signal<{ + readonly family?: string; + readonly customerId?: string; + }> = computed(() => ({ + ...(this.family() === 'fire' ? { family: 'fire' } : {}), + ...(this.customerId() ? { customerId: this.customerId() } : {}), + })); + /** + * Property queueFilters + * @readonly + * + * @description + * Additional equipment filter shared with the dashboard queue definition. + * + * @access protected + * @since unreleased + * + * @type {Signal<{ readonly status?: string; readonly maintenanceDueStatus?: string }>} + */ + protected readonly queueFilters: Signal<{ + readonly status?: string; + readonly maintenanceDueStatus?: string; + }> = computed(() => + this.queue() === 'unavailable' + ? { status: 'under_maintenance' } + : this.queue() === 'controls' + ? { maintenanceDueStatus: 'due' } + : {}, + ); + + //#endregion + //#region Constructor /** * Constructor @@ -1245,10 +1416,18 @@ export class OrganizationAssetsPage { */ effect((): void => { const axis: string | undefined = this.axisParam(); + const family = this.familyParam(); + const customer = this.customerParam(); + const queue = this.queueParam(); const facilityId: string | undefined = this.facilityParam(); const complianceFacilityId: string | undefined = this.complianceParam(); untracked((): void => { + this.family.set(family === 'all' ? 'all' : 'fire'); + this.customerId.set(customer ?? ''); + this.queue.set( + queue === 'unavailable' || queue === 'controls' || queue === 'anomalies' ? queue : '', + ); const restored: OrganizationAssetsAxis = axis === 'everything' || axis === 'compliance' ? axis : 'site'; @@ -1294,8 +1473,10 @@ export class OrganizationAssetsPage { effect((): void => { const organizationId: string = this.organizationId(); + const customerId = this.customerId(); untracked((): void => { - this.tree.loadRoots(organizationId); + this.tree.loadRoots(organizationId, customerId || null); + this.catalog.load(organizationId); }); }); @@ -1338,6 +1519,9 @@ export class OrganizationAssetsPage { const axis: OrganizationAssetsAxis = this.axis(); const facilityId: string | null = this.selectedFacilityId(); const includeDescendants = this.equipmentIncludeDescendants(); + const filters = this.parkFilters(); + const queueFilters = this.queueFilters(); + const queue = this.queue(); const canReadEquipment: boolean = this.canReadEquipment(); const canReadInspections: boolean = this.canReadInspections(); @@ -1347,15 +1531,93 @@ export class OrganizationAssetsPage { const scope = axis === 'site' && facilityId !== null ? { facilityId } : {}; + if (queue === 'anomalies') { + if (canReadEquipment && canReadInspections) + this.pane.loadAnomalies({ + organizationId, + ...scope, + ...(scope.facilityId ? { includeDescendants } : {}), + ...filters, + }); + return; + } if (canReadEquipment) - this.pane.loadEquipment({ organizationId, ...scope, includeDescendants }); - if (canReadInspections) this.pane.loadInspections({ organizationId, ...scope }); + this.pane.loadEquipment({ + organizationId, + ...scope, + includeDescendants, + ...filters, + ...queueFilters, + }); + if (canReadInspections) + this.pane.loadInspections({ + organizationId, + ...scope, + ...(axis === 'site' && facilityId !== null ? { includeDescendants } : {}), + ...filters, + }); }); }); } //#endregion //#region Methods + /** + * Method familyChanged + * @method + * + * @description + * Applies a server family filter and preserves the selected site context. + * + * @access protected + * @since unreleased + * + * @param {unknown} value - Value supplied by the owning park workflow. + * + * @returns {void} No return value. + */ + protected familyChanged(value: unknown): void { + if (value !== 'fire' && value !== 'all') return; + this.family.set(value); + this.writeUrlState(); + } + /** + * Method customerChanged + * @method + * + * @description + * Changes the customer authority and clears an incompatible selected site. + * + * @access protected + * @since unreleased + * + * @param {string} value - Value supplied by the owning park workflow. + * + * @returns {void} No return value. + */ + protected customerChanged(value: string): void { + if (value === this.customerId()) return; + this.customerId.set(value); + this.selectedFacilityId.set(null); + this.writeUrlState(); + } + /** + * Method clearQueue + * @method + * + * @description + * Clears a scoped dashboard queue while retaining client, site and family. + * + * @access protected + * @since unreleased + * + * @returns {void} No return value. + */ + protected clearQueue(): void { + this.queue.set(''); + this.writeUrlState(); + } + /** * Method retryPane * @@ -1383,19 +1645,30 @@ export class OrganizationAssetsPage { * * @returns {void} */ - protected changePanePage(kind: 'equipment' | 'inspections', page: number): void { + protected changePanePage(kind: 'equipment' | 'inspections' | 'anomalies', page: number): void { const facilityId = this.selectedFacilityId(); const request = { + ...this.parkFilters(), organizationId: this.organizationId(), page, ...(this.axis() === 'site' && facilityId !== null ? { facilityId } : {}), }; + if (kind === 'anomalies' && this.canReadEquipment() && this.canReadInspections()) + this.pane.loadAnomalies({ + ...request, + ...(request.facilityId ? { includeDescendants: this.equipmentIncludeDescendants() } : {}), + }); if (kind === 'equipment' && this.canReadEquipment()) this.pane.loadEquipment({ ...request, + ...this.queueFilters(), includeDescendants: this.equipmentIncludeDescendants(), }); - if (kind === 'inspections' && this.canReadInspections()) this.pane.loadInspections(request); + if (kind === 'inspections' && this.canReadInspections()) + this.pane.loadInspections({ + ...request, + ...(request.facilityId ? { includeDescendants: this.equipmentIncludeDescendants() } : {}), + }); } /** @@ -1415,13 +1688,31 @@ export class OrganizationAssetsPage { const facilityId: string | null = this.selectedFacilityId(); const scope = this.axis() === 'site' && facilityId !== null ? { facilityId } : {}; + if (this.queue() === 'anomalies') { + if (this.canReadEquipment() && this.canReadInspections()) + this.pane.loadAnomalies({ + organizationId, + ...scope, + ...this.parkFilters(), + ...(scope.facilityId ? { includeDescendants: this.equipmentIncludeDescendants() } : {}), + }); + return; + } if (this.canReadEquipment()) this.pane.loadEquipment({ organizationId, ...scope, + ...this.parkFilters(), + ...this.queueFilters(), includeDescendants: this.equipmentIncludeDescendants(), }); - if (this.canReadInspections()) this.pane.loadInspections({ organizationId, ...scope }); + if (this.canReadInspections()) + this.pane.loadInspections({ + organizationId, + ...scope, + ...this.parkFilters(), + ...(scope.facilityId ? { includeDescendants: this.equipmentIncludeDescendants() } : {}), + }); } /** @@ -1748,11 +2039,29 @@ export class OrganizationAssetsPage { */ protected typeLabelOf(type: string): string { return ( - EQUIPMENT_TYPE_OPTIONS.find((option) => option.value === (type as EquipmentType))?.label ?? + this.catalog.options().find((option) => option.value === type)?.label ?? type.replaceAll('_', ' ') ); } + /** + * Method equipmentTitleOf + * @method + * + * @description + * Equipment identity uses the name and asset reference, with the authorized server type label. + * + * @access protected + * @since unreleased + * + * @param {EquipmentOutput} item - Value supplied by the owning park workflow. + * + * @returns {string} Result consumed by the owning park workflow. + */ + protected equipmentTitleOf(item: EquipmentOutput): string { + return buildEquipmentTitle(item, this.typeLabelOf(item.type)); + } + /** * Method equipmentSecondaryLineOf * @@ -1768,6 +2077,13 @@ export class OrganizationAssetsPage { * @returns {string | null} The secondary line, or `null`. */ protected equipmentSecondaryLineOf(item: EquipmentOutput): string | null { + if (item.name || item.assetCode) + return [ + this.typeLabelOf(item.type), + item.serialNumber || [item.brand, item.model].filter(Boolean).join(' '), + ] + .filter(Boolean) + .join(' · '); if (item.serialNumber) return item.serialNumber; const parts: readonly string[] = [item.brand, item.model].filter( (part): part is string => !!part, @@ -1947,6 +2263,9 @@ export class OrganizationAssetsPage { relativeTo: this.route, queryParams: { axis: axis === 'site' ? null : axis, + family: this.family() === 'all' ? 'all' : null, + customerId: this.customerId() || null, + queue: this.queue() || null, facility: axis === 'site' ? this.selectedFacilityId() : null, equipmentScope: axis === 'site' ? equipmentScope : null, compliance: axis === 'compliance' ? this.selectedComplianceFacilityId() : null, diff --git a/src/app/features/organization/ui/pages/organization-assets-page/testing/organization-assets-page.component.spec.ts b/src/app/features/organization/ui/pages/organization-assets-page/testing/organization-assets-page.component.spec.ts index b14059eee..b35fbd6e4 100644 --- a/src/app/features/organization/ui/pages/organization-assets-page/testing/organization-assets-page.component.spec.ts +++ b/src/app/features/organization/ui/pages/organization-assets-page/testing/organization-assets-page.component.spec.ts @@ -15,6 +15,8 @@ import { } from '@core/request-state'; import { THEME_PORT, type ThemePort } from '@core/theme'; import { OrganizationPermissionService } from '@features/organization/access'; +import { CustomerService } from '@features/organization/features/customers/data-access'; +import { EquipmentTypeCatalogStore } from '@features/organization/features/equipments'; import { EquipmentService } from '@features/organization/features/equipments/data-access'; import type { FacilityOutput } from '@features/organization/features/facilities/models'; import { @@ -118,6 +120,13 @@ const snapshot = ( }); describe('OrganizationAssetsPage', () => { + beforeAll(() => { + globalThis.ResizeObserver ??= class { + observe(): void {} + unobserve(): void {} + disconnect(): void {} + } as unknown as typeof ResizeObserver; + }); let mobile: WritableSignal; let fixture: ComponentFixture; let hasPermission: ReturnType; @@ -187,9 +196,17 @@ describe('OrganizationAssetsPage', () => { downloadingSnapshotIdSignal = signal(null); TestBed.overrideComponent(OrganizationAssetsPage, { - remove: { providers: [FacilityOptionsStore] }, + remove: { providers: [FacilityOptionsStore, EquipmentTypeCatalogStore] }, add: { providers: [ + { + provide: EquipmentTypeCatalogStore, + useValue: { + load: vi.fn(), + options: signal([]), + loadCallState: signal(idleCallState()), + }, + }, { provide: FacilityOptionsStore, useValue: { @@ -256,6 +273,12 @@ describe('OrganizationAssetsPage', () => { { provide: OrganizationAssetsPaneStore, useValue: { + anomalies: signal([]), + anomaliesPage: signal(1), + anomaliesPageCount: signal(1), + anomaliesTotal: signal(0), + anomaliesCallState: signal(successCallState([])), + loadAnomalies: vi.fn(), equipment: signal([]), equipmentListCallState: signal(successCallState([])), equipmentTotal: equipmentTotalSignal, @@ -304,6 +327,13 @@ describe('OrganizationAssetsPage', () => { }, { provide: FeedbackService, useValue: { success: feedbackSuccess, error: feedbackError } }, { provide: EquipmentService, useValue: { exportLabels } }, + { + provide: CustomerService, + useValue: { + list: vi.fn().mockReturnValue(of({ member: [], totalItems: 0 })), + get: vi.fn().mockReturnValue(of({ id: 'customer', name: 'Hospital', contacts: [] })), + }, + }, { provide: BrowserDownloadService, useValue: { trigger: triggerDownload } }, { provide: OrganizationPermissionService, @@ -406,7 +436,7 @@ describe('OrganizationAssetsPage', () => { it('loads the site roots on arrival', async () => { fixture = await createPage(); - expect(loadRoots).toHaveBeenCalledWith('org-1'); + expect(loadRoots).toHaveBeenCalledWith('org-1', null); }); it('disables mobile dragging while keeping the site menu and desktop drag available', async () => { @@ -559,11 +589,14 @@ describe('OrganizationAssetsPage', () => { await fixture.whenStable(); expect(loadEquipment).toHaveBeenCalledWith({ + family: 'fire', includeDescendants: true, organizationId: 'org-1', facilityId: 'facility-1', }); expect(loadInspections).toHaveBeenCalledWith({ + family: 'fire', + includeDescendants: true, organizationId: 'org-1', facilityId: 'facility-1', }); @@ -576,10 +609,28 @@ describe('OrganizationAssetsPage', () => { await fixture.whenStable(); expect(loadEquipment).toHaveBeenCalledWith({ + family: 'fire', includeDescendants: true, organizationId: 'org-1', }); - expect(loadInspections).toHaveBeenCalledWith({ organizationId: 'org-1' }); + expect(loadInspections).toHaveBeenCalledWith({ + organizationId: 'org-1', + family: 'fire', + }); + + loadInspections.mockClear(); + fixture.componentInstance['changePanePage']('inspections', 2); + expect(loadInspections).toHaveBeenCalledExactlyOnceWith({ + organizationId: 'org-1', + family: 'fire', + page: 2, + }); + loadInspections.mockClear(); + fixture.componentInstance['retryPane'](); + expect(loadInspections).toHaveBeenCalledExactlyOnceWith({ + organizationId: 'org-1', + family: 'fire', + }); }); it('pages the selected site resources independently and retries in the same scope', async () => { @@ -596,6 +647,7 @@ describe('OrganizationAssetsPage', () => { fixture.componentInstance['changePanePage']('equipment', 3); expect(loadEquipment).toHaveBeenCalledExactlyOnceWith({ + family: 'fire', includeDescendants: true, organizationId: 'org-1', facilityId: 'facility-1', @@ -605,6 +657,8 @@ describe('OrganizationAssetsPage', () => { fixture.componentInstance['changePanePage']('inspections', 2); expect(loadInspections).toHaveBeenCalledExactlyOnceWith({ + family: 'fire', + includeDescendants: true, organizationId: 'org-1', facilityId: 'facility-1', page: 2, @@ -613,11 +667,14 @@ describe('OrganizationAssetsPage', () => { loadInspections.mockClear(); fixture.componentInstance['retryPane'](); expect(loadEquipment).toHaveBeenCalledWith({ + family: 'fire', includeDescendants: true, organizationId: 'org-1', facilityId: 'facility-1', }); expect(loadInspections).toHaveBeenCalledWith({ + family: 'fire', + includeDescendants: true, organizationId: 'org-1', facilityId: 'facility-1', }); @@ -1209,12 +1266,14 @@ describe('OrganizationAssetsPage', () => { equipmentScope: 'direct', }); expect(loadEquipment).toHaveBeenLastCalledWith({ + family: 'fire', organizationId: 'org-1', facilityId: 'facility-1', includeDescendants: false, }); fixture.componentInstance['changePanePage']('equipment', 2); expect(loadEquipment).toHaveBeenLastCalledWith({ + family: 'fire', organizationId: 'org-1', facilityId: 'facility-1', includeDescendants: false, @@ -1222,6 +1281,7 @@ describe('OrganizationAssetsPage', () => { }); fixture.componentInstance['retryPane'](); expect(loadEquipment).toHaveBeenLastCalledWith({ + family: 'fire', organizationId: 'org-1', facilityId: 'facility-1', includeDescendants: false, @@ -1229,6 +1289,7 @@ describe('OrganizationAssetsPage', () => { fixture.componentInstance['changeEquipmentScope'](true); await fixture.whenStable(); expect(loadEquipment).toHaveBeenLastCalledWith({ + family: 'fire', organizationId: 'org-1', facilityId: 'facility-1', includeDescendants: true, @@ -1252,6 +1313,7 @@ describe('OrganizationAssetsPage', () => { expect(page.equipmentScope()).toBe('direct'); expect(loadEquipment).toHaveBeenLastCalledWith({ + family: 'fire', organizationId: 'org-1', facilityId: 'facility-1', includeDescendants: false, @@ -1263,6 +1325,7 @@ describe('OrganizationAssetsPage', () => { ); expect(page.equipmentScope()).toBe('subtree'); expect(loadEquipment).toHaveBeenLastCalledWith({ + family: 'fire', organizationId: 'org-1', facilityId: 'facility-1', includeDescendants: true, @@ -1300,9 +1363,96 @@ describe('OrganizationAssetsPage', () => { expect(subtree?.getAttribute('aria-pressed')).toBe('false'); expect(direct?.getAttribute('aria-pressed')).toBe('true'); expect(loadEquipment).toHaveBeenLastCalledWith({ + family: 'fire', organizationId: 'org-1', facilityId: 'facility-1', includeDescendants: false, }); }); + + it('restores a complete customer park link and applies the same context to site roots and resources', async () => { + fixture = await createPage({ + organizationId: 'org-1', + axis: 'everything', + family: 'all', + customerId: 'customer', + }); + expect(loadRoots).toHaveBeenLastCalledWith('org-1', 'customer'); + expect(loadEquipment).toHaveBeenLastCalledWith({ + organizationId: 'org-1', + includeDescendants: true, + customerId: 'customer', + }); + expect(loadInspections).toHaveBeenLastCalledWith({ + organizationId: 'org-1', + customerId: 'customer', + }); + }); + it('opens dashboard due and anomaly queues without reverting to unfiltered equipment', async () => { + fixture = await createPage({ + organizationId: 'org-1', + axis: 'everything', + queue: 'controls', + customerId: 'customer', + }); + expect(loadEquipment).toHaveBeenLastCalledWith({ + organizationId: 'org-1', + includeDescendants: true, + family: 'fire', + customerId: 'customer', + maintenanceDueStatus: 'due', + }); + fixture.componentRef.setInput('queue', 'anomalies'); + await fixture.whenStable(); + const pane = fixture.debugElement.injector.get(OrganizationAssetsPaneStore); + expect(pane.loadAnomalies).toHaveBeenCalledWith({ + organizationId: 'org-1', + family: 'fire', + customerId: 'customer', + }); + expect( + fixture.nativeElement.querySelector('[data-testid="park-anomalies-pane"]'), + ).not.toBeNull(); + expect(fixture.nativeElement.querySelector('[data-testid="assets-equipment-pane"]')).toBeNull(); + }); + + it('applies the direct scope to anomaly routes, paging, retry and the visible scope toggle', async () => { + fixture = await createPage({ + organizationId: 'org-1', + facility: 'facility-1', + customerId: 'customer', + equipmentScope: 'direct', + queue: 'anomalies', + }); + const pane = fixture.debugElement.injector.get(OrganizationAssetsPaneStore); + const directRequest = { + organizationId: 'org-1', + facilityId: 'facility-1', + family: 'fire', + customerId: 'customer', + includeDescendants: false, + }; + expect(pane.loadAnomalies).toHaveBeenLastCalledWith(directRequest); + fixture.componentInstance['changePanePage']('anomalies', 2); + expect(pane.loadAnomalies).toHaveBeenLastCalledWith({ ...directRequest, page: 2 }); + fixture.componentInstance['retryPane'](); + expect(pane.loadAnomalies).toHaveBeenLastCalledWith(directRequest); + const subtree = fixture.nativeElement.querySelector( + '[data-testid="assets-equipment-scope-subtree"]', + ) as HTMLButtonElement | null; + expect(subtree).not.toBeNull(); + subtree?.click(); + await fixture.whenStable(); + expect(pane.loadAnomalies).toHaveBeenLastCalledWith({ + ...directRequest, + includeDescendants: true, + }); + const direct = fixture.nativeElement.querySelector( + '[data-testid="assets-equipment-scope-direct"]', + ) as HTMLButtonElement | null; + expect(direct).not.toBeNull(); + direct?.click(); + await fixture.whenStable(); + expect(pane.loadAnomalies).toHaveBeenLastCalledWith(directRequest); + }); }); diff --git a/src/app/features/organization/ui/pages/organization-dashboard-page/organization-dashboard-page.component.html b/src/app/features/organization/ui/pages/organization-dashboard-page/organization-dashboard-page.component.html index 94bb5469c..b1741cc5b 100644 --- a/src/app/features/organization/ui/pages/organization-dashboard-page/organization-dashboard-page.component.html +++ b/src/app/features/organization/ui/pages/organization-dashboard-page/organization-dashboard-page.component.html @@ -1,4 +1,7 @@
+ @if (organizationContext.selectedOrganizationId(); as organizationId) { + + } @if (dashboardStore.queryHasError() && isForbidden(dashboardStore.queryError())) {
diff --git a/src/app/features/organization/ui/pages/organization-dashboard-page/organization-dashboard-page.component.ts b/src/app/features/organization/ui/pages/organization-dashboard-page/organization-dashboard-page.component.ts index ea5f6df8d..7e90c104f 100644 --- a/src/app/features/organization/ui/pages/organization-dashboard-page/organization-dashboard-page.component.ts +++ b/src/app/features/organization/ui/pages/organization-dashboard-page/organization-dashboard-page.component.ts @@ -60,6 +60,7 @@ import { } from '@features/organization/state/organization-dashboard'; import { + OrganizationParkQueues, OrganizationTrendChartNotice, StatTile, OrganizationDashboardRisk, @@ -376,6 +377,7 @@ type OrganizationDashboardHealthRow = { @Component({ selector: 'app-organization-dashboard-page', imports: [ + OrganizationParkQueues, DecimalPipe, OrgDatePipe, OrganizationDashboardRisk, diff --git a/src/app/features/organization/ui/pages/organization-dashboard-page/testing/organization-dashboard-page.component.spec.ts b/src/app/features/organization/ui/pages/organization-dashboard-page/testing/organization-dashboard-page.component.spec.ts index f5c4e9354..25f08f0b5 100644 --- a/src/app/features/organization/ui/pages/organization-dashboard-page/testing/organization-dashboard-page.component.spec.ts +++ b/src/app/features/organization/ui/pages/organization-dashboard-page/testing/organization-dashboard-page.component.spec.ts @@ -1,9 +1,14 @@ import { signal } from '@angular/core'; import { TestBed, type ComponentFixture } from '@angular/core/testing'; import { provideRouter } from '@angular/router'; +import { of } from 'rxjs'; import { INTERACTION_CAPABILITIES_PORT } from '@core/interaction-capabilities'; import { toStoreError, type StoreError } from '@core/request-state'; import { OrganizationPermissionService } from '@features/organization/access'; +import { ParkService } from '@features/organization/data-access'; +import { CustomerService } from '@features/organization/features/customers/data-access'; +import { EquipmentService } from '@features/organization/features/equipments/data-access'; +import { FacilityService } from '@features/organization/features/facilities/data-access'; import { ORGANIZATION_PERMISSION, type OrganizationDashboardRecentIntervention, @@ -106,6 +111,36 @@ describe('OrganizationDashboardPage', () => { TestBed.configureTestingModule({ providers: [ provideRouter([]), + { + provide: EquipmentService, + useValue: { + summary: vi.fn().mockReturnValue(of({ byStatus: { under_maintenance: 0 } })), + summaryByFacility: vi.fn().mockReturnValue(of({ byStatus: { under_maintenance: 0 } })), + list: vi.fn().mockReturnValue(of({ member: [], totalItems: 0 })), + listByFacility: vi.fn().mockReturnValue(of({ member: [], totalItems: 0 })), + }, + }, + { + provide: ParkService, + useValue: { + anomaliesSummary: vi.fn().mockReturnValue(of({ openAnomalies: 0, bySeverity: {} })), + }, + }, + { + provide: FacilityService, + useValue: { + list: vi.fn().mockReturnValue(of({ member: [], totalItems: 0 })), + get: vi.fn(), + }, + }, + { + provide: CustomerService, + useValue: { + list: vi.fn().mockReturnValue(of({ member: [], totalItems: 0 })), + get: vi.fn(), + }, + }, + { provide: INTERACTION_CAPABILITIES_PORT, useValue: { diff --git a/src/app/features/organization/ui/pages/organization-more-page/organization-more-page.component.ts b/src/app/features/organization/ui/pages/organization-more-page/organization-more-page.component.ts index b7f15dc56..273dea8af 100644 --- a/src/app/features/organization/ui/pages/organization-more-page/organization-more-page.component.ts +++ b/src/app/features/organization/ui/pages/organization-more-page/organization-more-page.component.ts @@ -9,6 +9,7 @@ import { lucideClipboardList, lucideCreditCard, lucideHistory, + lucideLayoutDashboard, lucideWebhook, lucideListChecks, lucidePackage, @@ -75,6 +76,7 @@ import { HlmLarge } from '@shared/ui/typography'; lucideClipboardList, lucideCreditCard, lucideHistory, + lucideLayoutDashboard, lucideWebhook, lucideListChecks, lucidePackage, diff --git a/src/locale/messages.es.xlf b/src/locale/messages.es.xlf index 339f32d74..206bed681 100644 --- a/src/locale/messages.es.xlf +++ b/src/locale/messages.es.xlf @@ -1991,8 +1991,8 @@ Marca / modelo - Maintenance - Mantenimiento + Controls + Inspecciones Status @@ -3447,8 +3447,8 @@ Enviando… - Only the action is required; target and assignee can be set now or during execution. - Solo se requiere la acción; el objetivo y el asignado pueden definirse ahora o durante la ejecución. + Choose the action and, for maintenance, the equipment concerned. Assignment and estimates are optional. + Elija la acción y, para el mantenimiento, el equipo correspondiente. La asignación y las estimaciones son opcionales. Target resource @@ -15221,6 +15221,46 @@ Log time Registrar tiempo + + Correction history is unavailable offline. Reconnect to load earlier versions. + El historial de correcciones no está disponible sin conexión. Conéctese para cargar las versiones anteriores. + + + Reconnect to review the current version of this saved correction. + Conéctese para revisar la versión actual de esta corrección guardada. + + + Checking saved correction + Comprobando la corrección guardada + + + This journal page has not been saved on this device. Reconnect to load it. + Esta página del registro no está guardada en este dispositivo. Conéctese para cargarla. + + + Synchronize or review this pending change before loading correction history. + Sincronice o revise este cambio pendiente antes de cargar el historial de correcciones. + + + Loading correction history + Cargando el historial de correcciones + + + Load earlier versions + Cargar versiones anteriores + + + Time journal pages + Páginas del registro de tiempo + + + Page + Página + + + Saved entries: + Entradas guardadas: + No journal has been saved on this device. Reconnect to load its history. No hay ningún registro guardado en este dispositivo. Conéctese para cargar el historial. @@ -17981,6 +18021,3977 @@ Choose its type and name, then a compatible parent for buildings, floors, zones and areas. Sites stay at the root. Other details can be completed later. Elige su tipo y nombre y, para edificios, plantas, zonas y áreas, un elemento superior compatible. Los sitios permanecen en la raíz. Los demás datos pueden completarse más adelante. + + Customers + Clientes + + + Customers + Clientes + + + Internal customer records and contacts for your sites. + Fichas de clientes y contactos internos para sus sitios. + + + Customers are managed online + Los clientes se gestionan con conexión + + + Reconnect to update customer records. Your draft is preserved. + Vuelva a conectarse para actualizar los clientes. Se conserva su borrador. + + + Search customers + Buscar clientes + + + Customer status + Estado de los clientes + + + Active + Activos + + + Archived + Archivados + + + Customers could not be loaded + No se pudieron cargar los clientes + + + Customer action failed + La acción sobre el cliente ha fallado + + + Actions + Acciones + + + Restore + Restaurar + + + Archive + Archivar + + + No customers in this view + No hay clientes en esta vista + + + Change your search or create a customer. Sites can also be managed without a customer. + Cambie la búsqueda o cree un cliente. Los sitios también pueden gestionarse sin cliente. + + + New customer + Nuevo cliente + + + Edit customer + Editar cliente + + + Keep the customer's identity and contacts together for its sites. + Reúna la identidad y los contactos del cliente para sus sitios. + + + Refresh the latest version and keep my draft + Actualizar a la última versión y conservar mi borrador + + + Customer information + Información del cliente + + + Name + Nombre + + + Enter the customer name. + Introduzca el nombre del cliente. + + + Internal reference + Referencia interna + + + Email + Correo electrónico + + + Enter a valid email address. + Introduzca una dirección de correo válida. + + + Telephone + Teléfono + + + Role + Función + + + Contacts + Contactos + + + Contact + Contacto + + + Enter the contact name. + Introduzca el nombre del contacto. + + + Keep at most 50 contacts per customer. + Mantenga un máximo de 50 contactos por cliente. + + + Contacts are internal records. They do not receive access to FireGuard. + Los contactos son fichas internas. No reciben acceso a FireGuard. + + + Add contact + Añadir contacto + + + Remove contact + Quitar contacto + + + Customer could not be saved + No se pudo guardar el cliente + + + Save customer + Guardar cliente + + + Create customer + Crear cliente + + + Choose a customer + Elegir cliente + + + No customer + Sin cliente + + + (archived) + (archivado) + + + No matching customers + No hay clientes coincidentes + + + (archived) + (archivado) + + + Loading customer + Cargando cliente + + + Customer + Cliente + + + Optional. Assign a customer to this root site, or manage it directly as an operator. + Opcional. Asigne un cliente a este sitio raíz o gestiónelo directamente como operador. + + + Use at most 160 characters. + Use como máximo 160 caracteres. + + + Use at most 80 characters. + Use como máximo 80 caracteres. + + + Use at most 40 characters. + Use como máximo 40 caracteres. + + + Operation plans + Planes de operaciones + + + Control + Control + + + Maintenance + Mantenimiento + + + Historical cadence retained + Periodicidad histórica conservada + + + Anchored calendar + Calendario anclado + + + Prepared + Preparado + + + View equipment + Ver el equipo + + + Next due: + Próximo vencimiento: + + + First deadline to configure — no validated control recorded. + Primer vencimiento pendiente de configurar — no hay ningún control validado registrado. + + + Original deadline: · attempt + Vencimiento original: · intento + + + Open existing intervention + Abrir la intervención existente + + + Preview dates + Ver las próximas fechas + + + Configure + Configurar + + + Prepare intervention + Preparar la intervención + + + Start a new attempt + Iniciar un nuevo intento + + + Historical schedules + Programaciones históricas + + + Prepare an operation plan + Preparar un plan de operación + + + Keep controls and maintenance independent. Preparing work never marks an operation as completed. + Siga los controles y el mantenimiento de forma independiente. Preparar trabajos nunca valida una operación. + + + Scheduling authority unavailable + Motor de planificación no disponible + + + Historical scheduling remains active + La planificación histórica sigue activa + + + Prepare the historical plans and review their dates before switching. Existing intervals and recorded history are retained. Only one scheduling engine generates work. + Prepare los planes históricos y revise sus fechas antes del cambio. Se conservan los intervalos existentes y el historial registrado. Un único motor de planificación genera trabajos. + + + Prepare historical plans + Preparar los planes históricos + + + Review scheduling switch + Revisar el cambio de planificación + + + Operation configuration + Configuración de la operación + + + Close preview + Cerrar la vista previa + + + The server calculates these next dates. Review them before activating this operation. + El servidor calcula estas próximas fechas. Revíselas antes de activar esta operación. + + + Next three deadlines + Tres próximos vencimientos + + + Configure the first deadline to preview this historical operation. + Configure el primer vencimiento para ver las fechas de esta operación histórica. + + + Activate this operation + Activar esta operación + + + Pause this operation + Pausar esta operación + + + Intervention could not be prepared + No se ha podido preparar la intervención + + + Open intervention # + Abrir la intervención n.º + + + Operation deadlines + Vencimientos de las operaciones + + + Controls + Controles + + + Search operation plans + Buscar planes de operaciones + + + Operation plans could not be loaded + No se han podido cargar los planes de operaciones + + + No operation plans match this view + Ningún plan de operación coincide con esta vista + + + Adjust your search or prepare an equipment operation. + Ajuste la búsqueda o prepare una operación para un equipo. + + + Activate operation plans for this organization? + ¿Activar los planes de operaciones para esta organización? + + + This switches work generation from historical schedules to the prepared operation plans. The server checks existing campaigns and blocks ambiguous links. Historical intervals and results remain available. + La generación de trabajos pasará de las programaciones históricas a los planes de operaciones preparados. El servidor comprueba las campañas existentes y bloque las vinculaciones ambiguas. Los intervalos y resultados históricos siguen disponibles. + + + Activate operation plans + Activar los planes de operaciones + + + Operation plans + Planes de operaciones + + + Equipment + Equipo + + + No matching equipment loaded. + No se ha cargado ningún equipo coincidente. + + + Loading equipment… + Cargando equipos… + + + Previous equipment + Equipos anteriores + + + Next equipment + Equipos siguientes + + + Operation name + Nombre de la operación + + + Operation + Operación + + + Every + Cada + + + Calendar unit + Unidad de calendario + + + Choose a unit + Elegir una unidad + + + Anchor date + Fecha de anclaje + + + First due date + Primer vencimiento + + + Optional for a new plan; defaults to the anchor date. Historical equipment without a recorded control needs an explicit first deadline. + Opcional para un plan nuevo; se utiliza la fecha de anclaje por defecto. Un equipo histórico sin un control registrado necesita un primer vencimiento explícito. + + + Choose your organization's frequency. A monthly date uses the last day of shorter months and retains its original anchor. This configuration does not certify regulatory compliance. + Elija la periodicidad de su organización. Una fecha mensual utiliza el último día de los meses más cortos y conserva su anclaje original. Esta configuración no certifica el cumplimiento normativo. + + + Save and preview dates + Guardar y ver las próximas fechas + + + Select an equipment. + Seleccione un equipo. + + + Enter an operation name. + Introduzca un nombre de operación. + + + Use at most 160 characters. + Utilice un máximo de 160 caracteres. + + + Choose a calendar unit. + Elija una unidad de calendario. + + + Enter a positive interval. + Introduzca un intervalo positivo. + + + Enter a whole number. + Introduzca un número entero. + + + Choose an anchor date. + Elija una fecha de anclaje. + + + Days + Días + + + Weeks + Semanas + + + Months + Meses + + + Years + Años + + + The plan could not be saved. + No se ha podido guardar el plan. + + + Equipment name + Nombre del equipo + + + Asset reference + Referencia patrimonial + + + Unique within your organization when supplied. + Única en su organización cuando se indica. + + + Declared criticality + Criticidad declarada + + + Not specified + No especificada + + + Low + Baja + + + Medium + Media + + + High + Alta + + + Critical + Crítica + + + Technical characteristics + Características técnicas + + + Declared information about this equipment; it does not determine control obligations. + Información declarada sobre este equipo; no determina sus obligaciones de inspección. + + + Characteristic + Característica + + + Value + Valor + + + Unit + Unidad + + + Add a characteristic + Añadir una característica + + + Enter a characteristic name. + Introduzca un nombre de característica. + + + Enter a value. + Introduzca un valor. + + + Characteristic names must be unique. + Los nombres de las características deben ser únicos. + + + Declared operational status + Estado operativo declarado + + + Control follow-up + Seguimiento de las inspecciones + + + Open anomalies + Anomalías abiertas + + + Inspection access required + Se requiere acceso a las inspecciones + + + Unavailable + No disponible + + + Open the last control + Abrir la última inspección + + + Report a defect + Comunicar un defecto + + + Replace equipment + Sustituir el equipo + + + Replacement history + Historial de sustituciones + + + Open the replaced equipment + Abrir el equipo sustituido + + + Open the successor equipment + Abrir el equipo sucesor + + + From reserve + Desde la reserva + + + New equipment + Equipo nuevo + + + Successor equipment + Equipo sucesor + + + Search reserve equipment + Buscar un equipo en reserva + + + Select the successor equipment. + Seleccione el equipo sucesor. + + + Confirm replacement + Confirmar la sustitución + + + The response was lost. Retry the same operation to recover its result without creating a second replacement. + Se ha perdido la respuesta. Reintente la misma operación para recuperar el resultado sin crear una segunda sustitución. + + + Confirm the replacement + Confirmar la sustitución + + + will be retired. Its history remains accessible and the successor inherits its site and position. + se retirará. Su historial permanecerá accesible y el sucesor heredará su ubicación y posición. + + + will be retired permanently. Its dossier and history remain accessible through the successor. + se retirará definitivamente. Su expediente e historial permanecerán accesibles desde el sucesor. + + + Controls and maintenance + Inspecciones y mantenimiento + + + Prepare a control + Preparar una inspección + + + Prepare maintenance + Preparar mantenimiento + + + Organize a repair + Organizar una reparación + + + Intervention access required + Se requiere acceso a las intervenciones + + + Open work is unavailable. Retry before preparing a new intervention. + Los trabajos abiertos no están disponibles. Reintente antes de preparar una nueva intervención. + + + View maintenance plans + Ver planes de mantenimiento + + + Equipment park + Parque de equipos + + + Equipment coverage + Cobertura de los equipos + + + Fire equipment + Equipos contra incendios + + + All equipment + Todos los equipos + + + Show the complete park + Mostrar todo el parque + + + All sites + Todos los sitios + + + Site + Sitio + + + Park actions + Acciones del parque + + + Unavailable equipment + Equipos no disponibles + + + Controls to prepare + Controles pendientes de preparar + + + Anomalies to address + Anomalías pendientes + + + Some park counts could not be loaded + No se pudieron cargar algunos contadores del parque + + + No unresolved anomalies in this park scope + No hay anomalías sin resolver en esta cobertura + + + Open the source inspection + Abrir la inspección de origen + + + Open source inspection for + Abrir la inspección de origen de + + + Equipment types + Tipos de equipos + + + Manage the names and families available in your organization's inventory. + Gestione los nombres y las familias disponibles en el inventario de su organización. + + + New equipment type + Nuevo tipo de equipo + + + Edit equipment type + Editar tipo de equipo + + + Equipment types are managed online + Los tipos de equipos se gestionan en línea + + + Reconnect to save changes. Your draft is preserved. + Vuelva a conectarse para guardar los cambios. Se conserva su borrador. + + + Equipment type status + Estado del tipo de equipo + + + Active + Activos + + + Archived + Archivados + + + Equipment types could not be loaded + No se pudieron cargar los tipos de equipos + + + Equipment type could not be updated + No se pudo actualizar el tipo de equipo + + + Reload catalogue + Recargar catálogo + + + Actions + Acciones + + + Restore + Restaurar + + + Archive + Archivar + + + No equipment types in this view + No hay tipos de equipos en esta vista + + + Choose another view or create an equipment type. Archived types remain available in equipment histories. + Elija otra vista o cree un tipo de equipo. Los tipos archivados permanecen disponibles en el historial de los equipos. + + + Keep stable codes for equipment records and choose the family used to filter your inventory. + Mantenga códigos estables para los equipos y elija la familia utilizada para filtrar el inventario. + + + This type has changed + Este tipo ha cambiado + + + Reload the latest revision, compare its name and family with your draft, then save again. Your entered values are preserved. + Recargue la última revisión, compare su nombre y familia con su borrador y vuelva a guardar. Se conservan los valores introducidos. + + + Review latest revision + Revisar la última revisión + + + Reviewed server values: + Valores del servidor revisados: + + + Fire + Incendios + + + Safety + Seguridad + + + Other + Otros + + + Equipment type + Tipo de equipo + + + Permanent code + Código permanente + + + The code cannot be changed after creation. + El código no se puede cambiar después de la creación. + + + Use 1–32 lowercase letters, digits or underscores, starting with a letter. + Utilice entre 1 y 32 letras minúsculas, dígitos o guiones bajos, comenzando por una letra. + + + Name + Nombre + + + Enter an equipment type name. + Introduzca el nombre del tipo de equipo. + + + Use at most 100 characters for the name. + Utilice un máximo de 100 caracteres para el nombre. + + + Family + Familia + + + Choose an equipment family. + Elija una familia de equipos. + + + Save type + Guardar tipo + + + Create type + Crear tipo + + + Equipment type could not be saved + No se pudo guardar el tipo de equipo + + + Preventive maintenance + Mantenimiento preventivo + + + Corrective maintenance + Mantenimiento correctivo + + + Maintenance + Mantenimiento + + + Repair + Reparación + + + Replacement + Sustitución + + + Choose the equipment this work concerns. + Elija el equipo al que se refieren estos trabajos. + + + Select the equipment to keep its maintenance history traceable. + Seleccione el equipo para conservar la trazabilidad de su mantenimiento. + + + Select equipment + Seleccionar equipo + + + Enter when the work was performed. + Indique cuándo se realizaron los trabajos. + + + Enter a valid date and time in the displayed timezone. + Introduzca una fecha y hora válidas en la zona horaria mostrada. + + + Choose the result of this work. + Elija el resultado de estos trabajos. + + + Choose a result appropriate for this action. + Elija un resultado adecuado para esta acción. + + + Describe the work actually performed. + Describa los trabajos realmente realizados. + + + Keep the work description within 10,000 characters. + Limite la descripción de los trabajos a 10 000 caracteres. + + + The execution result could not be saved. + No se pudo guardar el resultado de ejecución. + + + Performed at + Realizado el + + + Date and time in . + Fecha y hora en . + + + Result + Resultado + + + Successful + Correcto + + + Performed + Realizado + + + Unsuccessful — work still required + Sin éxito — aún requiere trabajo + + + Work performed + Trabajos realizados + + + The result is recorded now and validated when the intervention is published. An unsuccessful result keeps the task open. + El resultado se registra ahora y se valida al publicar la intervención. Un resultado sin éxito mantiene la tarea abierta. + + + Record result + Registrar el resultado + + + Record actual work + Registrar los trabajos realizados + + + Performed + Realizado + + + Validated + Validado + + + Pending review + Pendiente de revisión + + + Previous attempts + Intentos anteriores + + + This task can no longer be executed in the current workflow. Your draft remains available. + Esta tarea ya no puede ejecutarse en el flujo actual. Su borrador sigue disponible. + + + Export validated report + Exportar el informe validado + + + Export draft report + Exportar el borrador del informe + + + Organization profile + Perfil de la organización + + + Site operator + Operador de sitios + + + Service provider + Proveedor de servicios + + + Choose how you manage your fire-safety equipment. Access remains determined by your organization's permissions. + Elija cómo gestionar su parque contra incendios. Los permisos de su organización determinan sus derechos. + + + Maintenance follow-up + Seguimiento del mantenimiento + + + This type is no longer available for new equipment. Choose an active type. + Este tipo ya no está disponible para equipos nuevos. Elija un tipo activo. + + + Equipment families + Familias de equipos + + + Fire equipment + Equipos contra incendios + + + All equipment + Todos los equipos + + + No active fire equipment types. Choose All equipment to view the other available types. + No hay tipos activos de equipos contra incendios. Elija Todos los equipos para ver los demás tipos disponibles. + + + No active equipment types + No hay tipos de equipos activos + + + An active type is needed to register equipment. Refresh the available choices after the organization's types have been updated. + Se necesita un tipo activo para registrar equipos. Actualice las opciones disponibles después de actualizar los tipos de la organización. + + + Next control + Próxima inspección + + + Next maintenance + Próximo mantenimiento + + + Replacement equipment + Equipo de sustitución + + + Checking the equipment replacement… + Comprobando la sustitución del equipo… + + + This published equipment is linked to the retired original. + Este equipo publicado está vinculado al equipo original retirado. + + + The replacement could not be checked. Keep your draft and refresh when the equipment dossier is available. + No se pudo comprobar la sustitución. Conserve su borrador y actualice cuando el expediente del equipo esté disponible. + + + Confirm the replacement in the equipment dossier, then refresh its successor. + Confirme la sustitución en el expediente del equipo y después actualice su sucesor. + + + Open equipment dossier + Abrir el expediente del equipo + + + Replacement equipment: + Equipo de sustitución: + + + Equipment types could not be loaded + No se han podido cargar los tipos de equipos + + + Repair requests + Solicitudes de reparación + + + Maintenance request + Solicitud de mantenimiento + + + Requested + Pendiente de evaluación + + + Qualified + Evaluada + + + Rejected + Rechazada + + + Cancelled + Cancelada + + + Converted into work + Trabajo organizado + + + Low + Baja + + + Normal + Normal + + + High + Alta + + + Urgent + Urgente + + + Qualify the maintenance need before explicitly organizing corrective work. + Evalúe la necesidad de mantenimiento antes de organizar explícitamente el trabajo correctivo. + + + The request has changed. Review its latest version before applying your draft. + La solicitud ha cambiado. Revise su última versión antes de aplicar su borrador. + + + Review latest version + Revisar la última versión + + + The current request state no longer allows this action. + El estado actual de la solicitud ya no permite esta acción. + + + Use reviewed version and keep my draft + Usar la versión revisada y conservar mi borrador + + + Saved maintenance operations could not be restored. Retry before starting another action. + No se han podido restaurar las operaciones de mantenimiento guardadas. Vuelva a intentarlo antes de iniciar otra acción. + + + This conversion could not be saved on this device. Your draft has been kept. Retry to send it. + No se ha podido guardar esta conversión en este dispositivo. Su borrador se ha conservado. Vuelva a intentarlo para enviarlo. + + + The work link has not been confirmed. Retry this conversion before choosing another action. + El vínculo con el trabajo no está confirmado. Reintente esta conversión antes de elegir otra acción. + + + Retry the same conversion + Reintentar la misma conversión + + + New maintenance request + Nueva solicitud de mantenimiento + + + Edit maintenance request + Editar la solicitud de mantenimiento + + + Qualify maintenance request + Evaluar la solicitud de mantenimiento + + + Reject request + Rechazar la solicitud + + + Cancel request + Cancelar la solicitud + + + Organize corrective work + Organizar el trabajo correctivo + + + Maintenance requests + Solicitudes de mantenimiento + + + Requested + Creada + + + Qualify request + Evaluar la solicitud + + + Maintenance target + Objeto del mantenimiento + + + Equipment + Equipo + + + Open equipment dossier + Abrir el expediente del equipo + + + Site + Sitio + + + Open site dossier + Abrir el expediente del sitio + + + Open source inspection + Abrir la inspección de origen + + + Qualification + Evaluación + + + Decision reason + Motivo de la decisión + + + Corrective work + Trabajo correctivo + + + Open corrective intervention + Abrir la intervención correctiva + + + This request could not be opened + No se pudo abrir esta solicitud + + + Describe maintenance needs, qualify their target, then organize corrective work. + Describa las necesidades de mantenimiento, evalúe su objeto y organice el trabajo correctivo. + + + Maintenance request submitted: + Solicitud de mantenimiento enviada: + + + You are offline. Your open draft is kept until you can submit it. + Está sin conexión. Su borrador abierto se conserva hasta que pueda enviarlo. + + + Search maintenance requests + Buscar solicitudes de mantenimiento + + + Request status + Estado de la solicitud + + + All statuses + Todos los estados + + + Requests could not be loaded + No se pudieron cargar las solicitudes + + + No maintenance requests + No hay solicitudes de mantenimiento + + + The current target and filters have no requests to display. + El objeto y los filtros actuales no contienen solicitudes para mostrar. + + + Clear filters + Borrar los filtros + + + Title + Título + + + Priority + Prioridad + + + Qualification note + Nota de evaluación + + + Link an existing corrective task or create an intervention for this qualified request. + Vincule una tarea correctiva existente o cree una intervención para esta solicitud evaluada. + + + Corrective work + Trabajo correctivo + + + Create a corrective intervention + Crear una intervención correctiva + + + Your permissions do not allow viewing existing interventions. + Sus permisos no permiten consultar las intervenciones existentes. + + + Loading open work + Cargando el trabajo abierto + + + Use at most 10000 characters. + Use como máximo 10 000 caracteres. + + + Use at most 2000 characters. + Use como máximo 2 000 caracteres. + + + Explain this decision. + Explique esta decisión. + + + Identify the equipment requiring maintenance. + Identifique el equipo que requiere mantenimiento. + + + Choose current corrective work. + Elija trabajo correctivo aún abierto. + + + Open corrective work + Trabajo correctivo abierto + + + Confirm qualification + Confirmar la evaluación + + + Link this corrective work + Vincular este trabajo correctivo + + + Create corrective intervention + Crear la intervención correctiva + + + A site-only request can be qualified once its equipment is identified. + Una solicitud para un sitio se puede evaluar una vez identificado su equipo. + + + Maintenance need + Necesidad de mantenimiento + + + Describe the request with a title. + Describa la solicitud con un título. + + + Describe the maintenance need. + Describa la necesidad de mantenimiento. + + + Use at most 160 characters. + Use como máximo 160 caracteres. + + + Choose equipment or a site. + Elija un equipo o un sitio. + + + Save request + Guardar la solicitud + + + Submit maintenance request + Enviar la solicitud de mantenimiento + + + No equipment selected + Ningún equipo seleccionado + + + Unavailable for a new request + No disponible para una nueva solicitud + + + Equipment selection requires permission to read the park. + La selección de un equipo requiere permiso para consultar el parque. + + + Choose equipment + Elegir un equipo + + + Site selection requires permission to read sites. + La selección de un sitio requiere permiso para consultar los sitios. + + + Parts and stock + Piezas y existencias + + + Purchasing and receipts + Compras y recepciones + + + Maintenance costs + Costes de mantenimiento + + + Request a repair + Solicitar una reparación + + + Choose a part + Elegir una pieza + + + Choose a warehouse + Elegir un almacén + + + (archived) + (archivado) + + + No matching references + No hay referencias coincidentes + + + Loading choices + Cargando opciones + + + Choices could not be loaded + No se pudieron cargar las opciones + + + Selected reference could not be loaded + No se pudo cargar la referencia seleccionada + + + Choose an active part. + Elija una pieza activa. + + + Choose an active warehouse. + Elija un almacén activo. + + + Enter the quantity used. + Introduzca la cantidad utilizada. + + + Enter a positive decimal with up to 18 whole digits and 6 decimal places, using a dot. + Introduzca un decimal positivo de hasta 18 dígitos enteros y 6 decimales, usando un punto. + + + Declare parts used + Declarar las piezas utilizadas + + + Record the physical quantity used. Stock is confirmed after synchronization. + Registre la cantidad física utilizada. El stock se confirma después de la sincronización. + + + Part + Pieza + + + Warehouse + Almacén + + + Quantity used + Cantidad utilizada + + + Declaration could not be queued + No se pudo guardar la declaración en la cola + + + Declare consumption + Declarar el consumo + + + The declaration is retained here until it is safely queued. Retry keeps the same recorded facts. + La declaración se conserva aquí hasta que se guarde en la cola local. Al reintentar se conservan los mismos datos. + + + Consumption history + Historial de consumos + + + These states describe stock recording. They do not indicate completed work or a validated dossier. + Estos estados describen el registro del stock. No indican un trabajo terminado ni un expediente validado. + + + No consumption has been declared for this work. + No se ha declarado ningún consumo para este trabajo. + + + Stock debit confirmed + Salida de stock confirmada + + + Received — reconciliation needed + Recibido — conciliación necesaria + + + No stock balance is recorded for this source. + No hay un saldo de stock registrado para este origen. + + + The part or warehouse is archived. + La pieza o el almacén está archivado. + + + Recorded stock is insufficient for the full quantity. + El stock registrado es insuficiente para la cantidad completa. + + + Sending — stock not confirmed + Enviando — stock no confirmado + + + Queued locally — retry needed + En la cola local — debe reintentarse + + + Queued locally — awaiting synchronization + En la cola local — pendiente de sincronización + + + Recorded after publication; the published dossier remains unchanged. + Registrado después de la publicación; el expediente publicado permanece sin cambios. + + + Parts used + Piezas utilizadas + + + Hide declarations + Ocultar declaraciones + + + View and declare parts + Consultar y declarar piezas + + + Declaration not yet saved on this device + La declaración aún no se ha guardado en este dispositivo + + + Keep this page open. Retry the same declaration before leaving or publishing. + Mantén esta página abierta. Vuelve a intentar guardar la misma declaración antes de salir o publicar. + + + Stock preparation could not finish + No se ha podido completar la preparación de existencias + + + Retry preparation + Reintentar la preparación + + + Connect and prepare this intervention’s parts and warehouses before declaring them offline. + Conéctate y prepara las piezas y almacenes de esta intervención antes de declararlos sin conexión. + + + References saved on this device. Stock availability is confirmed when declarations reach the server. + Referencias guardadas en este dispositivo. La disponibilidad de existencias se confirma cuando las declaraciones llegan al servidor. + + + Preparing stock references and declarations… + Preparando referencias de existencias y declaraciones… + + + Used for + Utilizadas para + + + Whole intervention + Toda la intervención + + + Selecting a task links the parts used to that work and its equipment. + Al elegir una tarea, las piezas utilizadas quedan vinculadas al trabajo y a su equipo. + + + Work item + Tarea + + + A declaration added after publication updates current stock records. The published dossier keeps its original data. + Una declaración añadida tras la publicación actualiza el registro actual de existencias. El expediente publicado conserva sus datos originales. + + + View internal maintenance costs + Consultar los costes internos de mantenimiento + + + Save the submitted parts declaration on this device before publishing. Retry the declaration in Parts used. + Guarda la declaración de piezas en este dispositivo antes de publicar. Reintenta la declaración en Piezas utilizadas. + + + Save this intervention on the device before preparing its stock references. + Guarda esta intervención en el dispositivo antes de preparar sus referencias de existencias. + + + This intervention must be saved on this device before a consumption can be queued. Retry preparing the workspace. + La intervención debe estar guardada en este dispositivo antes de poner un consumo en cola. Reintenta la preparación del expediente. + + + This declaration identifier already belongs to another physical fact. Keep the original declaration and review it before continuing. + El identificador de esta declaración ya corresponde a otro hecho físico. Conserva la declaración original y revísala antes de continuar. + + + Review the conflicted declaration before retrying it. + Revisa la declaración en conflicto antes de reintentarla. + + + The declaration history could not be verified for this intervention. Refresh before continuing. + No se ha podido verificar el historial de declaraciones de esta intervención. Actualízalo antes de continuar. + + + The stock references changed or could not be downloaded completely. Retry preparation. + Las referencias de existencias han cambiado o no se han podido descargar completamente. Reintenta la preparación. + + + The server received this declaration, but its receipt could not be saved on this device. Keep the queued declaration and synchronize again. + El servidor ha recibido esta declaración, pero no se ha podido guardar su recibo en este dispositivo. Conserva la declaración en cola y vuelve a sincronizar. + + + Parts used + Piezas utilizadas + + + Inventory + Inventario + + + Stock + Existencias + + + Parts and consumables + Piezas y consumibles + + + Warehouses + Almacenes + + + Consumption declarations + Declaraciones de consumo + + + Movement history + Historial de movimientos + + + Enter a permanent code. + Introduzca un código permanente. + + + Enter a name. + Introduzca un nombre. + + + Enter a stock unit. + Introduzca una unidad de existencias. + + + Stock reference + Referencia de existencias + + + Permanent code + Código permanente + + + Codes and part categories cannot be changed after creation. + Los códigos y las categorías de piezas no se pueden cambiar después de crearlos. + + + Name + Nombre + + + Stock unit + Unidad de existencias + + + For example: piece, metre or litre. Quantities use this unit. + Por ejemplo: pieza, metro o litro. Las cantidades utilizan esta unidad. + + + Category + Categoría + + + Part + Pieza + + + Consumable + Consumible + + + The reference could not be saved + No se pudo guardar la referencia + + + Warehouse + Almacén + + + Quantity + Cantidad + + + Enter a non-zero decimal with up to 18 whole digits and 6 decimal places, using a dot. + Introduzca un decimal distinto de cero, con hasta 18 cifras enteras y 6 decimales, usando un punto. + + + Explain why this movement is needed. + Explique por qué es necesario este movimiento. + + + Movement details + Detalles del movimiento + + + Use a negative quantity to remove stock, or a positive quantity to add it. + Utilice una cantidad negativa para retirar existencias o positiva para añadirlas. + + + Enter the quantity physically returned from this consumption. + Introduzca la cantidad devuelta físicamente de este consumo. + + + Reason + Motivo + + + The movement could not be confirmed + No se pudo confirmar el movimiento + + + Record movement + Registrar movimiento + + + Received after publication + Recibido después de la publicación + + + Open intervention + Abrir intervención + + + Restore + Restaurar + + + Archive + Archivar + + + Reconcile stock + Conciliar existencias + + + Record return + Registrar devolución + + + No stock records in this view + No hay registros de existencias en esta vista + + + Change your filters or add a reference. Archived references remain readable in the history. + Cambie los filtros o añada una referencia. Las referencias archivadas siguen disponibles en el historial. + + + Stock pages + Páginas de existencias + + + Previous + Anterior + + + Next + Siguiente + + + Page of + Página de + + + Receipt + Recepción + + + Supplier return + Devolución al proveedor + + + Consumption + Consumo + + + Return + Devolución + + + Correction + Corrección + + + No stock balance is available for this part and warehouse. + No hay saldo de existencias disponible para esta pieza y este almacén. + + + This part or warehouse is archived. Review the retained declaration before reconciling. + Esta pieza o este almacén está archivado. Revise la declaración conservada antes de conciliar. + + + The declared quantity exceeds available stock. The full declaration is retained without a partial debit. + La cantidad declarada supera las existencias disponibles. Se conserva toda la declaración sin una salida parcial. + + + Part or consumable + Pieza o consumible + + + Warehouse + Almacén + + + Record stock correction + Registrar corrección de existencias + + + Archived + Archivado + + + Active + Activo + + + Confirmed + Confirmado + + + Received — to reconcile + Recibido — por conciliar + + + Track parts and consumables separately from individual equipment. Confirmed movements update stock; retained declarations stay visible until reconciled. + Controle piezas y consumibles por separado de los equipos individualizados. Los movimientos confirmados actualizan las existencias; las declaraciones conservadas siguen visibles hasta su conciliación. + + + New part or consumable + Nueva pieza o consumible + + + New warehouse + Nuevo almacén + + + Inventory sections + Secciones del inventario + + + Stock confirmation requires a connection + La confirmación de existencias requiere conexión + + + Reconnect to read current stock or confirm a movement. Unacknowledged movements remain saved on this device. + Vuelva a conectarse para consultar las existencias actuales o confirmar un movimiento. Los movimientos sin confirmar siguen guardados en este dispositivo. + + + Saved movements could not be read + No se pudieron leer los movimientos guardados + + + Movements awaiting confirmation + Movimientos pendientes de confirmación + + + The response may have been lost. Retry the saved movement to obtain confirmation with the same identity. + Es posible que se haya perdido la respuesta. Reintente el movimiento guardado para obtener su confirmación con la misma identidad. + + + Retry saved movement + Reintentar movimiento guardado + + + Search by code or name + Buscar por código o nombre + + + Reference status + Estado de la referencia + + + Declaration status + Estado de la declaración + + + All + Todos + + + Apply filters + Aplicar filtros + + + A declaration is a retained physical fact + Una declaración conserva un hecho físico + + + A pending declaration does not debit part of the stock or complete the work. Reconciliation confirms the entire quantity; dossier validation remains an intervention decision. + Una declaración pendiente no descuenta parte de las existencias ni completa el trabajo. La conciliación confirma toda la cantidad; la validación del expediente corresponde a la intervención. + + + Stock records could not be loaded + No se pudieron cargar los registros de existencias + + + The operation could not be confirmed + No se pudo confirmar la operación + + + Preserve stable references and record motivated stock movements without changing history. + Conserve referencias estables y registre movimientos de existencias justificados sin cambiar el historial. + + + This movement keeps one identity + Este movimiento mantiene una sola identidad + + + Keep this identity when retrying. The stock is confirmed only by the server response. + Mantenga esta identidad al reintentar. Solo la respuesta del servidor confirma las existencias. + + + Close + Cerrar + + + Unknown + Desconocido + + + Choose the actual UTC offset + Elija el desfase UTC real + + + UTC offset for this repeated hour + Desfase UTC de esta hora repetida + + + The same local time occurred twice because the clocks changed. Select the UTC offset when the expense actually occurred. + Esta hora local se produjo dos veces por el cambio de hora. Elija el desfase UTC del momento en que se produjo realmente el gasto. + + + This local hour occurs twice. Choose the UTC offset of the actual expense. + Esta hora local se produce dos veces. Elija el desfase UTC del gasto real. + + + Enter a valid actual date that is not in the future. + Introduce una fecha real válida que no esté en el futuro. + + + Adjust the original expense + Ajustar el gasto original + + + Exact amount + Importe exacto + + + Enter the exact expense amount. + Introduce el importe exacto del gasto. + + + Cancel adjustment + Cancelar ajuste + + + Actual date and time + Fecha y hora reales + + + Enter the actual expense date and time. + Introduce la fecha y la hora reales del gasto. + + + Description or correction reason + Descripción o motivo de corrección + + + Enter an exact decimal with at most six decimal places. A negative correction requires its original expense. + Introduce un número decimal exacto con un máximo de seis decimales. Una corrección negativa debe estar vinculada al gasto original. + + + Expenses and corrections are appended to the history. The closure snapshot remains unchanged. + Los gastos y las correcciones se añaden al historial. Los costes fijados al cierre no cambian. + + + Describe the expense or explain its correction. + Describe el gasto o explica su corrección. + + + Record expense + Registrar gasto + + + Record adjustment + Registrar ajuste + + + Add an external expense + Añadir un gasto externo + + + Actions + Acciones + + + Adjust expense + Ajustar gasto + + + Traceable adjustment + Ajuste trazable + + + Exact amount + Importe exacto + + + Private maintenance cost contributions + Contribuciones a los costes internos de mantenimiento + + + Occurred on + Fecha de realización + + + Contribution + Contribución + + + No cost contributions recorded + No hay contribuciones a los costes registradas + + + Recorded work time, material use and external expenses will appear here. + Aquí aparecerán los tiempos de trabajo, los consumos de material y los gastos externos registrados. + + + Financial information could not be saved. + No se ha podido guardar la información financiera. + + + Financial information saved. + Información financiera guardada. + + + Enter a non-negative decimal with at most six decimal places. + Introduce un número decimal igual o mayor que cero con un máximo de seis decimales. + + + Enter whole minutes between zero and 100000000. + Introduce un número entero de minutos entre cero y 100000000. + + + External expense + Gasto externo + + + External service + Servicio externo + + + Materials + Materiales + + + Work time + Tiempo de trabajo + + + Browse interventions + Consultar intervenciones + + + Captured on + Fecha de registro + + + Choose an intervention + Elegir una intervención + + + Open an intervention's financial dossier to prepare its budget and follow its costs. + Abre el expediente financiero de una intervención para preparar su presupuesto y realizar el seguimiento de sus costes. + + + Current realized costs + Costes realizados actuales + + + Prepare budgets and follow internal operational costs. Commercial invoicing remains in your ERP. + Prepara los presupuestos y realiza el seguimiento de los costes operativos internos. La facturación comercial permanece en tu ERP. + + + Intervention costs + Costes de la intervención + + + Costs recorded at closure + Costes registrados al cierre + + + Later expenses, returns and corrected time affect the current costs while this original snapshot stays unchanged. + Los gastos posteriores, las devoluciones y las correcciones de tiempo modifican los costes actuales. El estado original al cierre no cambia. + + + Some valuations were unknown at closure. + Se desconocían algunas valoraciones al cierre. + + + Immutable snapshot + Estado fijado al cierre + + + The valuation is incomplete + La valoración está incompleta + + + Some prices or hourly rates are unknown. Known contributions are shown separately and missing values are not treated as zero. + Se desconocen algunos precios o tarifas por hora. Las contribuciones conocidas se muestran por separado y los valores que faltan no se consideran cero. + + + Known contributions + Contribuciones conocidas + + + Open intervention + Abrir intervención + + + Financial preparation is closed. The recorded budget is retained. + La preparación financiera está cerrada. Se conserva el presupuesto registrado. + + + Private financial information + Información financiera interna + + + Financial information is available only in an authorized browser session. + La información financiera solo está disponible en una sesión de navegador autorizada. + + + Could not load the financial dossier + No se ha podido cargar el expediente financiero + + + Reload + Volver a cargar + + + Your original declaration is retained. Retry it unchanged to confirm whether it was saved. + Se conserva tu declaración original. Reinténtala sin cambios para confirmar si se ha guardado. + + + Retry the original declaration + Reintentar la declaración original + + + Currency and hourly rates + Moneda y tarifas por hora + + + Complete realized total + Total realizado completo + + + Confirmation is still needed + Todavía se necesita confirmación + + + Use the latest version for these edits + Aplicar estos cambios a la última versión + + + Add estimated resource + Añadir un recurso estimado + + + Estimated amount + Importe estimado + + + Planned budget + Presupuesto previsto + + + The financial preparation has changed + La preparación financiera ha cambiado + + + Your edits are retained. Compare them with the refreshed budget before using its latest version. + Se conservan tus cambios. Compáralos con el presupuesto actualizado antes de utilizar su última versión. + + + Description + Descripción + + + Describe this estimated resource. + Describe este recurso estimado. + + + Resource type + Tipo de recurso + + + Estimated minutes + Duración estimada en minutos + + + Quantity + Cantidad + + + Read the latest budget for comparison + Consultar el último presupuesto para compararlo + + + Remove resource + Quitar recurso + + + Estimated resource + Recurso estimado + + + Save financial preparation + Guardar preparación financiera + + + Financial preparation + Preparación financiera + + + Unit or hourly amount + Importe por unidad o por hora + + + Leave an estimate blank when it is unknown. Zero is a known value. + Deja una estimación en blanco si se desconoce. Cero es un valor conocido. + + + Add an hourly rate + Añadir una tarifa por hora + + + Hourly amount + Importe por hora + + + per hour. Use a decimal point and up to six decimal places. + por hora. Utiliza un punto decimal y hasta seis decimales. + + + Enter a nonnegative amount with no more than six decimal places, using a decimal point. + Introduce un importe igual o mayor que cero, con un máximo de seis decimales y un punto decimal. + + + Set the organization currency before adding a rate. + Establece la moneda de la organización antes de añadir una tarifa. + + + This rate applies from the selected calendar day. + Esta tarifa se aplica desde la fecha seleccionada. + + + Choose a valid effective date. + Elige una fecha de vigencia válida. + + + Effective from + Vigente desde + + + A new rate takes effect from its date. Earlier rates and recorded costs stay unchanged. + Una nueva tarifa entra en vigor desde su fecha. Las tarifas anteriores y los costes registrados no cambian. + + + Member + Miembro + + + Choose a member + Elegir un miembro + + + Choose an available organization member. + Elige un miembro disponible de la organización. + + + Member directory access is required to choose a member and add a rate. + Se requiere acceso al directorio de miembros para elegir un miembro y añadir una tarifa. + + + No available members. Refresh the member directory before adding a rate. + No hay miembros disponibles. Actualiza el directorio de miembros antes de añadir una tarifa. + + + Loading members… + Cargando miembros… + + + Member details unavailable + Información del miembro no disponible + + + Retry original submission + Reintentar el envío original + + + Add rate + Añadir tarifa + + + Your rate draft is preserved. Try again when the request has settled. + Se conserva la tarifa introducida. Inténtalo de nuevo cuando finalice la solicitud. + + + The rate could not be confirmed + No se ha podido confirmar la tarifa + + + Keep this draft unchanged. Retry the original submission to confirm whether the rate was recorded. + Mantén esta información sin cambios. Reintenta el envío original para confirmar si se registró la tarifa. + + + The rate submission is awaiting confirmation + El envío de la tarifa está pendiente de confirmación + + + Currency code + Código de moneda + + + Operational costs share one currency. It is locked after the first financial record. + Los costes operativos utilizan una única moneda. Se bloquea tras el primer registro financiero. + + + Use a three-letter uppercase code, for example EUR. Currency is locked after the first financial record. + Utiliza un código de tres letras mayúsculas, por ejemplo EUR. La moneda se bloquea tras el primer registro financiero. + + + Currency could not be loaded + No se ha podido cargar la moneda + + + Loading currency… + Cargando moneda… + + + Locked + Bloqueada + + + Financial records already use this currency. It can no longer be changed. + Ya existen registros financieros con esta moneda. No se puede cambiar. + + + Reload currency + Volver a cargar la moneda + + + Enter a three-letter uppercase currency code. + Introduce un código de moneda de tres letras mayúsculas. + + + Save currency + Guardar moneda + + + Your currency draft is preserved. Try saving again. + Se conserva la moneda introducida. Intenta guardarla de nuevo. + + + Currency could not be saved + No se ha podido guardar la moneda + + + Organization currency + Moneda de la organización + + + Current currency + Moneda actual + + + No currency has been set. + No se ha establecido ninguna moneda. + + + Immutable hourly rates by member and effective date + Tarifas por hora inmutables por miembro y fecha de vigencia + + + Rates are dated and immutable. Add a new rate when an amount changes. + Las tarifas tienen fecha y son inmutables. Añade una nueva tarifa cuando cambie el importe. + + + No hourly rates have been recorded for this organization. + No se han registrado tarifas por hora para esta organización. + + + No hourly rates yet + Todavía no hay tarifas por hora + + + Rate history could not be loaded + No se ha podido cargar el historial de tarifas + + + Loading rate history… + Cargando el historial de tarifas… + + + Reload rate history + Volver a cargar el historial de tarifas + + + Hourly rate history + Historial de tarifas por hora + + + Try loading this information again. + Intenta cargar esta información de nuevo. + + + Reserve equipment could not be created because the organization quota was reached. + No se han podido crear los equipos en reserva porque se ha alcanzado la cuota de la organización. + + + The physical return is retained. Inventory needs reconciliation before its movement can be confirmed. + La devolución física se conserva. Es necesario conciliar el inventario antes de confirmar su movimiento. + + + The equipment type is currently unavailable. The physical receipt is retained. + El tipo de equipo no está disponible actualmente. La recepción física se conserva. + + + Equipment types could not be loaded + No se han podido cargar los tipos de equipos + + + Operation could not be confirmed + No se ha podido confirmar la operación + + + Confirm operation + Confirmar operación + + + The supplier remains in existing purchases and history. New orders require an active supplier. + El proveedor se conserva en las compras existentes y en su historial. Los nuevos pedidos requieren un proveedor activo. + + + Archive this supplier? + ¿Archivar este proveedor? + + + Only quantities still awaiting delivery are cancelled. Previous receipts and returns remain retained. + Solo se cancelan las cantidades pendientes de entrega. Las recepciones y devoluciones anteriores se conservan. + + + Cancel undelivered quantities? + ¿Cancelar las cantidades no entregadas? + + + The draft becomes an order. Physical deliveries will be recorded separately. + El borrador se convierte en un pedido. Las entregas físicas se registrarán por separado. + + + Place this purchase order? + ¿Confirmar este pedido de compra? + + + The server tries to confirm the inventory reversal. The original physical quantity and reason stay unchanged if stock is still insufficient. + El servidor intenta confirmar el movimiento de devolución del inventario. La cantidad física y el motivo originales permanecen intactos si las existencias siguen siendo insuficientes. + + + Reconcile this physical return? + ¿Conciliar esta devolución física? + + + Confirm operation + Confirmar operación + + + The server creates a bounded set of individually identified reserve equipment. A quota or unavailable type keeps the physical receipt awaiting action. + El servidor crea un conjunto limitado de equipos en reserva, cada uno con su identidad. Una cuota alcanzada o un tipo no disponible mantiene la recepción física pendiente de actuación. + + + Create reserve equipment for this receipt? + ¿Crear los equipos en reserva de esta recepción? + + + Organization currency. Leave empty when unknown. No commercial tax or billing is calculated here. + Moneda de la organización. Déjelo vacío si se desconoce el coste. Aquí no se calculan impuestos comerciales ni facturación. + + + Enter a nonnegative amount with at most six decimals. + Introduzca un importe igual o superior a cero con un máximo de seis decimales. + + + Internal unit cost + Coste unitario interno + + + Internal cost incomplete — unit cost unknown. + Coste interno incompleto: coste unitario desconocido. + + + Internal unit cost: + Coste unitario interno: + + + Prepare purchases, record physical deliveries and follow reserve equipment or stock without losing the source history. + Prepare las compras, registre las entregas físicas y siga los equipos en reserva o las existencias conservando su historial de origen. + + + Keep the source record and the actual physical quantities together. + Mantenga unidos el registro de origen y las cantidades físicas reales. + + + Purchase draft + Borrador de compra + + + Record a physical delivery + Registrar una entrega física + + + Record a physical return + Registrar una devolución física + + + Supplier information + Información del proveedor + + + Procurement + Compras + + + Equipment to individualize + Equipo pendiente de individualización + + + Stock article + Artículo de inventario + + + Stock article label unavailable + Nombre del artículo de inventario no disponible + + + Ordered: + Pedido: + + + Record a delivery + Registrar una entrega + + + Physically received: + Recibido físicamente: + + + Awaiting delivery: + Pendiente de entrega: + + + Physically returned: + Devuelto físicamente: + + + unit unavailable + unidad no disponible + + + units + unidades + + + Add a line + Añadir una línea + + + Stock article + Artículo de inventario + + + Brand + Marca + + + Choose a catalogue type + Elegir un tipo del catálogo + + + Received materials await explicit creation as reserve equipment. They are not quantitative stock. + Los materiales recibidos esperan su creación explícita como equipos en reserva. No constituyen existencias cuantitativas. + + + Equipment name + Nombre del equipo + + + Purchase draft + Borrador de compra + + + Item kind + Clase de artículo + + + Order line + Línea de pedido + + + Add between 1 and 100 lines. + Añada entre 1 y 100 líneas. + + + Loading suppliers… + Cargando proveedores… + + + Model + Modelo + + + Order name + Nombre del pedido + + + Enter the order name. + Introduzca el nombre del pedido. + + + Next suppliers + Proveedores siguientes + + + No active matching supplier loaded. + No se ha cargado ningún proveedor activo que coincida. + + + Choose a stock article. + Elija un artículo de inventario. + + + Previous suppliers + Proveedores anteriores + + + Remove line + Eliminar línea + + + Save purchase draft + Guardar borrador de compra + + + Purchase draft could not be saved + No se ha podido guardar el borrador de compra + + + Internal supplier + Proveedor interno + + + Choose an internal supplier. + Elija un proveedor interno. + + + Equipment type + Tipo de equipo + + + Choose an equipment type. + Elija un tipo de equipo. + + + All orders + Todos los pedidos + + + Cancel undelivered quantities + Cancelar cantidades no entregadas + + + Close order + Cerrar pedido + + + New purchase draft + Nuevo borrador de compra + + + Created: + Creado: + + + Source order could not be loaded + No se ha podido cargar el pedido de origen + + + Edit purchase draft + Editar borrador de compra + + + No purchase orders match this view + No hay pedidos de compra que coincidan con esta vista + + + Choose another status or prepare an internal purchase. + Elija otro estado o prepare una compra interna. + + + Order status filter + Filtrar por estado del pedido + + + Purchase orders could not be loaded + No se han podido cargar los pedidos de compra + + + Place purchase order + Confirmar pedido de compra + + + View receipts + Ver recepciones + + + Use a decimal point and at most six decimal places. Quantities and amounts remain exact. + Use un punto decimal y un máximo de seis decimales. Las cantidades y los importes se mantienen exactos. + + + Enter a positive quantity up to 100000 with at most six decimals. Equipment needs whole units. + Introduzca una cantidad positiva de hasta 100000 con un máximo de seis decimales. Los equipos requieren unidades enteras. + + + Quantity + Cantidad + + + Choose the actual UTC offset + Elegir el desfase UTC real + + + Use the local date and time of your device. The recorded instant retains its timezone. + Use la fecha y hora locales de su dispositivo. El instante registrado conserva su zona horaria. + + + Materials remain in this receipt until you explicitly create their reserve equipment identities. No quantitative stock is added. + Los materiales permanecen en esta recepción hasta que cree explícitamente sus identidades como equipos en reserva. No se añaden existencias cuantitativas. + + + Receipt could not be confirmed + No se ha podido confirmar la recepción + + + UTC offset for this repeated hour + Desfase UTC de esta hora repetida + + + The same local time occurred twice because the clocks changed. Select the offset shown on the physical receipt record. + Esta hora local se produjo dos veces por el cambio de hora. Elija el desfase indicado en el justificante de la recepción física. + + + This local hour occurs twice. Choose the UTC offset of the actual receipt. + Esta hora local se produce dos veces. Elija el desfase UTC de la recepción real. + + + Enter a positive quantity within the remaining delivery. Equipment needs whole units, up to 100 per receipt. + Introduzca una cantidad positiva que no supere lo pendiente de entrega. Los equipos requieren unidades enteras, hasta 100 por recepción. + + + Record physical receipt + Registrar recepción física + + + Still awaiting delivery: . This receipt can contain up to . + Pendiente de entrega: . Esta recepción puede contener hasta . + + + Physical receipt date and time + Fecha y hora de recepción física + + + Choose a valid receipt date and time that is not in the future. + Elija una fecha y hora de recepción válidas que no sean futuras. + + + Enter the physical receipt date and time. + Introduzca la fecha y hora de recepción física. + + + Receiving warehouse + Almacén de recepción + + + Choose the receiving warehouse. + Elija el almacén de recepción. + + + No physical receipts recorded + No hay recepciones físicas registradas + + + Record the actual delivery from its source line above. Placing an order does not receive its goods. + Registre la entrega real desde su línea de origen de arriba. Confirmar un pedido no equivale a recibir sus mercancías. + + + Create reserve equipment + Crear equipos en reserva + + + Receipts could not be loaded + No se han podido cargar las recepciones + + + Return movement awaiting reconciliation: + Movimiento de devolución pendiente de conciliación: + + + Received: + Recibido: + + + Open reserve equipment dossier + Abrir el expediente del equipo en reserva + + + Record a physical return + Registrar una devolución física + + + Physically returned: + Devuelto físicamente: + + + View return history + Ver historial de devoluciones + + + Select the source purchase order + Seleccione el pedido de compra de origen + + + Open an order's receipts to keep its physical deliveries and returns together. + Abra las recepciones de un pedido para mantener juntas sus entregas y devoluciones físicas. + + + Physical receipt history + Historial de recepciones físicas + + + Return could not be confirmed + No se ha podido confirmar la devolución + + + The return remains linked to its original receipt. If stock needs reconciliation, the physical declaration is retained until the movement can be confirmed. + La devolución permanece vinculada a su recepción original. Si es necesario conciliar el inventario, la declaración física se conserva hasta que pueda confirmarse el movimiento. + + + Received units not already returned: . + Unidades recibidas aún no devueltas: . + + + Enter a positive quantity within the received units not already returned. + Introduzca una cantidad positiva que no supere las unidades recibidas aún no devueltas. + + + Physical return reason + Motivo de la devolución física + + + Explain why the goods were returned. + Explique por qué se han devuelto las mercancías. + + + Record physical return + Registrar devolución física + + + No physical returns recorded + No hay devoluciones físicas registradas + + + Each quantity and reason remains linked to its original receipt. Reconciliation confirms a stock movement and never rewrites the physical declaration. + Cada cantidad y motivo permanecen vinculados a su recepción original. La conciliación confirma un movimiento de inventario sin reescribir la declaración física. + + + Return history could not be loaded + No se ha podido cargar el historial de devoluciones + + + Returned: + Devuelto: + + + Reconcile inventory movement + Conciliar movimiento de inventario + + + Physical returns and inventory reconciliation + Devoluciones físicas y conciliación del inventario + + + Your typed draft is retained. Load the latest source before choosing to use its revision. + El borrador introducido se conserva. Cargue la última versión del registro de origen antes de elegir su revisión. + + + Load latest source + Cargar última versión + + + Review the latest source revision + Revisar la última revisión del registro + + + Use reviewed revision and keep my draft + Usar la revisión comprobada y conservar mi borrador + + + Awaiting reserve equipment + Pendiente de equipos en reserva + + + Awaiting inventory reconciliation + Pendiente de conciliación del inventario + + + Remaining quantities cancelled + Cantidades restantes canceladas + + + Draft + Borrador + + + Reserve equipment created + Equipos en reserva creados + + + Ordered + Pedido + + + Partially received + Recibido parcialmente + + + Received + Recibido + + + Inventory reversal confirmed + Movimiento de devolución del inventario confirmado + + + Returned + Devuelto + + + Stock received + Existencias recibidas + + + Add contact + Añadir contacto + + + Internal reference + Referencia interna + + + Contact + Contacto + + + Enter the contact name. + Introduzca el nombre del contacto. + + + Contacts + Contactos + + + Contacts are internal records. They do not receive access to FireGuard. + Los contactos son registros internos. No reciben acceso a FireGuard. + + + Keep at most 50 contacts per supplier. + Mantenga un máximo de 50 contactos por proveedor. + + + Create supplier + Crear proveedor + + + Email + Correo electrónico + + + Enter a valid email address. + Introduzca una dirección de correo electrónico válida. + + + Supplier information + Información del proveedor + + + Use at most 160 characters. + Use un máximo de 160 caracteres. + + + Use at most 40 characters. + Use un máximo de 40 caracteres. + + + Use at most 80 characters. + Use un máximo de 80 caracteres. + + + Name + Nombre + + + Enter the supplier name. + Introduzca el nombre del proveedor. + + + Telephone + Teléfono + + + Remove contact + Eliminar contacto + + + Role + Función + + + Save supplier + Guardar proveedor + + + Supplier could not be saved + No se ha podido guardar el proveedor + + + Active suppliers + Proveedores activos + + + All suppliers + Todos los proveedores + + + Archive supplier + Archivar proveedor + + + Archived suppliers + Proveedores archivados + + + Archived + Archivado + + + New internal supplier + Nuevo proveedor interno + + + Edit supplier + Editar proveedor + + + No suppliers match this view + No hay proveedores que coincidan con esta vista + + + Adjust your search or create an internal supplier. + Ajuste la búsqueda o cree un proveedor interno. + + + Supplier archive filter + Filtrar proveedores archivados + + + Suppliers could not be loaded + No se han podido cargar los proveedores + + + Search suppliers + Buscar proveedores + + + Purchasing workspace + Espacio de compras + + + Orders + Pedidos + + + Receipts + Recepciones + + + Suppliers + Proveedores + + + The response was lost. Retry the same declaration to recover its result before entering another physical operation. + La respuesta se ha perdido. Reintente la misma declaración para recuperar su resultado antes de introducir otra operación física. + + + Recover the original operation result + Recuperar el resultado de la operación original + + + Choose a task + Elegir una tarea + + + Maintenance exports + Exportaciones de mantenimiento + + + Generate adjustment + Generar ajuste + + + Validated corrections produce a new linked archive. The original file and published dossier remain unchanged. + Las correcciones validadas crean un nuevo archivo vinculado. El archivo original y el expediente publicado no cambian. + + + Confirm actual import + Confirmar la importación realizada + + + Confirm only after your ERP has imported this exact archive. FireGuard does not send or import it automatically. + Confirme solo después de importar este archivo exacto en su ERP. FireGuard no lo envía ni lo importa automáticamente. + + + Actual external import reference + Referencia de la importación externa realizada + + + Reason for adjustment + Motivo del ajuste + + + Enter the reason or actual external import reference. + Introduzca el motivo o la referencia de la importación externa realizada. + + + Created + Creado + + + No retained export yet + Todavía no hay exportaciones conservadas + + + Generate an export from ready published maintenance dossiers. + Genere una exportación a partir de expedientes de mantenimiento publicados y preparados. + + + Page of + Página de + + + Private costs + Costes privados + + + Rows + Filas + + + External import state + Estado de importación externa + + + System and version + Sistema y versión + + + Retained archives + Archivos conservados + + + Append adjustment + Añadir ajuste + + + Confirm external import + Confirmar importación externa + + + Internal costs are incomplete. Unknown amounts are retained explicitly. + Los costes internos están incompletos. Los importes desconocidos se conservan de forma explícita. + + + Published dossiers + Expedientes publicados + + + Download retained CSV + Descargar el CSV conservado + + + Download retained JSON + Descargar el JSON conservado + + + The file has been generated. No external import has been confirmed. + El archivo se ha generado. No se ha confirmado ninguna importación externa. + + + External import confirmed + Importación externa confirmada + + + Open original export + Abrir la exportación original + + + Open corrected export + Abrir la exportación corregida + + + Private archive including internal costs. + Archivo privado que incluye los costes internos. + + + Retained prestation rows + Filas de servicios conservadas + + + Schema version + Versión del formato + + + Retained adjustment reason + Motivo del ajuste conservado + + + Append an adjustment + Añadir un ajuste + + + Use reviewed revision and keep my draft + Usar la versión revisada y conservar mi borrador + + + Confirm an external import + Confirmar una importación externa + + + The source changed or conflicts with this command. Your draft is retained. + El origen ha cambiado o entra en conflicto con esta operación. Su borrador se conserva. + + + Generate a prestation export + Generar una exportación de servicios + + + Operations retain their identity across retries. Review a confirmed conflict before submitting a new command. + Las operaciones conservan su identidad al reintentarse. Revise los conflictos confirmados antes de enviar una nueva operación. + + + Discard draft + Descartar borrador + + + Discard the local draft and close this sheet? + ¿Descartar el borrador local y cerrar este panel? + + + Keep editing + Seguir editando + + + Map an external resource reference + Vincular una referencia externa + + + Recover the same operation + Recuperar la misma operación + + + Load latest source + Cargar el origen actualizado + + + The response is uncertain. Recover this same operation before changing the draft. + La respuesta es incierta. Recupere esta misma operación antes de modificar el borrador. + + + Unknown costs remain explicitly incomplete. This archive will require financial read permission. + Los costes desconocidos permanecen explícitamente incompletos. Este archivo requerirá permiso de lectura financiera. + + + Published dossiers + Expedientes publicados + + + Generate retained export + Generar y conservar la exportación + + + Include internal costs in this private export + Incluir los costes internos en esta exportación privada + + + Loading dossiers + Cargando expedientes + + + No published dossier matches this search. + Ningún expediente publicado coincide con esta búsqueda. + + + No validated work is retained in this dossier. It cannot be exported. + Este expediente no conserva ningún trabajo validado. No puede exportarse. + + + Search published dossiers + Buscar expedientes publicados + + + dossier(s) selected. Selection is kept across pages. + expediente(s) seleccionado(s). La selección se conserva entre páginas. + + + Select between 1 and 100 ready published dossiers. + Seleccione entre 1 y 100 expedientes publicados preparados. + + + Historical publication snapshot missing. This dossier cannot be exported. + Falta la instantánea histórica de publicación. Este expediente no puede exportarse. + + + Page + Página + + + External system code + Código del sistema externo + + + Identifies your ERP or target system. References are frozen when the export is generated. + Identifica su ERP o sistema de destino. Las referencias se fijan al generar la exportación. + + + Use letters, numbers, dots, underscores or hyphens (up to 40 characters). + Use letras, números, puntos, guiones bajos o guiones (hasta 40 caracteres). + + + Enter the external system code. + Introduzca el código del sistema externo. + + + Adjustment + Ajuste + + + Initial export + Exportación inicial + + + Retain versioned CSV/JSON files and record actual ERP imports. Commercial invoicing remains in your ERP. + Conserve archivos CSV/JSON versionados y registre las importaciones realizadas en el ERP. La facturación comercial permanece en su ERP. + + + Generate export + Generar exportación + + + Map a reference + Vincular una referencia + + + Prestation exports + Exportaciones de servicios + + + Customer + Cliente + + + Current external references + Referencias externas actuales + + + Equipment + Equipo + + + Mapping changes affect future exports. Retained archives keep their original external references. + Los cambios de correspondencia se aplican a futuras exportaciones. Los archivos conservados mantienen sus referencias externas originales. + + + Loading resources + Cargando recursos + + + Additional mappings are available through the scoped resource selector. + Las demás correspondencias están disponibles en el selector de recursos del ámbito. + + + Page + Página + + + A readable customer, site or equipment directory permission is required to choose a mapping target. + Se requiere permiso de lectura del directorio de clientes, sitios o equipos para elegir una correspondencia. + + + Reading current reference + Leyendo la referencia actual + + + Save reviewed reference + Guardar referencia revisada + + + Search the resource directory + Buscar en el directorio de recursos + + + Site + Sitio + + + Scoped resource + Recurso del ámbito + + + Select an authorized resource. + Seleccione un recurso autorizado. + + + Resource type + Tipo de recurso + + + External resource reference + Referencia externa del recurso + + + Enter the external resource reference. + Introduzca la referencia externa del recurso. + + + Import confirmed + Importación confirmada + + + Export generated + Exportación generada + + + Use no more than 200 characters. + Use como máximo 200 caracteres. + + + Use no more than 1000 characters. + Use como máximo 1000 caracteres. + + + Clear search + Borrar la búsqueda + + + Find a work reference or name, open its private financial dossier, or narrow the economic report using its named scope. + Busque una referencia o un nombre de intervención, abra su expediente financiero privado o limite el informe económico a su ámbito identificado. + + + No financial dossiers in this scope + No hay expedientes financieros en este ámbito + + + Try another work reference, date window or named filter. + Pruebe otra referencia, otro periodo u otro filtro de ámbito. + + + Equipment report filters + Filtros del informe por equipo + + + Financial dossiers could not be loaded + No se pudieron cargar los expedientes financieros + + + Historical identity is incomplete. Only available named filters can be selected. + La identidad histórica está incompleta. Solo se pueden seleccionar los filtros con un nombre disponible. + + + Current work + Trabajo actual + + + Reading financial dossiers + Cargando los expedientes financieros + + + Historical snapshot missing + Falta la instantánea histórica + + + Open financial dossier + Abrir el expediente financiero + + + Published snapshot + Instantánea publicada + + + Published on + Publicado el + + + Retry directory + Volver a cargar el directorio + + + Limit to customer + Limitar al cliente + + + Limit to equipment + Limitar al equipo + + + Limit to site + Limitar al centro + + + Search dossiers + Buscar expedientes + + + Work reference or name + Referencia o nombre de la intervención + + + Use at most 160 characters for the work reference or name. + Use como máximo 160 caracteres para la referencia o el nombre de la intervención. + + + Financial dossiers + Expedientes financieros + + + Unnamed equipment + Equipo sin nombre + + + Read economic report + Consultar el informe económico + + + Clear all named filters + Quitar todos los filtros de ámbito + + + Clear customer filter + Quitar el filtro de cliente + + + Clear equipment filter + Quitar el filtro de equipo + + + Clear site filter + Quitar el filtro de centro + + + Customer + Cliente + + + Choose a valid calendar date. + Elija una fecha válida. + + + Equipment + Equipo + + + From + Desde + + + Choose the first report date. + Elija la primera fecha del informe. + + + Group costs by + Agrupar los costes por + + + Choose equipment, site or customer grouping. + Elija la agrupación por equipo, centro o cliente. + + + Choose up to 366 calendar days. The first and last dates are included. + Elija un periodo de hasta 366 días naturales. Se incluyen el primer y el último día. + + + Selected filters + Filtros seleccionados + + + Site + Centro + + + Report scope + Ámbito del informe + + + Through + Hasta + + + Choose the last report date. + Elija la última fecha del informe. + + + Unnamed equipment + Equipo sin nombre + + + Choose an ordered date window of at most 366 days, including both dates. + Elija un periodo ordenado de hasta 366 días, incluidos el primero y el último. + + + Budget + Presupuesto + + + Economic cost allocations + Asignaciones de costes económicos + + + Identity recorded at closure + Identidad registrada al cierre + + + Current realized cost + Coste realizado actual + + + Source dossiers + Expedientes de origen + + + No costs allocated in this window + No hay costes asignados en este periodo + + + Choose another date window or review the explicitly unallocated contributions. + Elija otro periodo o consulte las contribuciones explícitamente no asignadas. + + + Budget is shown separately from forecast cost. The variance compares current realized cost with the forecast. + El presupuesto se muestra por separado del coste previsto. La diferencia compara el coste realizado actual con el previsto. + + + Cost at closure + Coste al cierre + + + Allocation + Asignación + + + Allocation incomplete + Asignación incompleta + + + Identity is incomplete + Identidad incompleta + + + Known subtotal + Subtotal conocido + + + Current identity + Identidad actual + + + Reading economic allocations + Cargando las asignaciones económicas + + + Current and recorded identity + Identidad actual y registrada + + + Forecast cost + Coste previsto + + + Unallocated costs + Costes sin asignar + + + No allocation identity + Sin identidad de asignación + + + Historical identity unavailable + Identidad histórica no disponible + + + Unvalued contributions + Contribuciones sin valorar + + + Realized minus forecast + Realizado menos previsto + + + View source dossiers + Consultar los expedientes de origen + + + Allocated destinations + Destinos de los costes asignados + + + Browse financial dossiers and choose a scope + Consultar expedientes financieros y elegir un ámbito + + + The inclusive UTC date window selects published dossiers by publication date and open work by planned start, or creation date when no start is set. Costs show those dossiers' full current history, including later corrections. + El periodo inclusivo en UTC selecciona los expedientes publicados por fecha de publicación y los trabajos abiertos por inicio previsto, o por fecha de creación si no hay inicio. Los costes abarcan todo el historial actual de esos expedientes, incluidas las correcciones posteriores. + + + Compare internal maintenance spending with preparation, by equipment, site or customer. Each contribution is allocated once and unknown valuations remain visible. + Compare el gasto interno de mantenimiento con las previsiones, por equipo, centro o cliente. Cada contribución se asigna una sola vez y las valoraciones desconocidas siguen visibles. + + + Search the paginated financial directory to select a named site, customer or equipment. These minimal contexts are available with financial read permission alone. + Busque en el directorio financiero paginado para seleccionar un centro, cliente o equipo por su nombre. Esta información mínima está disponible únicamente con el permiso de lectura financiera. + + + Source dossiers: + Expedientes de origen: + + + No partial total is displayed. Check the dates and use a shorter period if the report is too large. Named target filters refine the selected dossiers; they do not increase the period's source limit. + No se muestra ningún total parcial. Revise las fechas y reduzca el periodo si el informe es demasiado amplio. Los filtros de destino precisan los expedientes seleccionados; no aumentan el límite de fuentes del periodo. + + + Financial response does not match the requested scope. + La respuesta financiera no corresponde al ámbito solicitado. + + + Could not prepare the financial report + No se pudo preparar el informe financiero + + + Published dossiers without an immutable closure snapshot: . Their current facts remain visible, while the missing closure values are explicit. + Expedientes publicados sin una instantánea inmutable del cierre: . Sus datos actuales siguen visibles y se indica la ausencia de los valores de cierre. + + + Some historical closure snapshots are missing + Algunos expedientes históricos no tienen una valoración fijada al cierre + + + Unknown prices, rates or estimates are not zero. Each incomplete metric shows its known subtotal, and a variance is available only when both amounts are complete. + Los precios, tarifas o previsiones desconocidos no equivalen a cero. Cada indicador incompleto muestra su subtotal conocido; la diferencia solo está disponible cuando ambos importes están completos. + + + Some valuations are unknown + Algunas valoraciones son desconocidas + + + Preparing the financial report + Preparando el informe financiero + + + Global budgets + Presupuestos globales + + + Current realized cost + Coste realizado actual + + + Recorded at closure + Registrado al cierre + + + Known: + Conocido: + + + Planned cost + Coste previsto + + + Realized cost minus estimate + Coste realizado menos previsión + + + Financial dossiers and settings + Expedientes financieros y ajustes + + + Open economic pilotage + Abrir el control económico + + + Resource estimates provide the planned cost. When a dossier has no estimated resources, its global budget serves as the forecast. The budget remains a separate metric and is never added to resource estimates. + Las estimaciones de recursos determinan el coste previsto. Cuando un expediente no tiene recursos estimados, su presupuesto global sirve de previsión. El presupuesto sigue siendo un indicador distinto y nunca se suma a las estimaciones de recursos. + + + Orders: · Receipts: · Receipts awaiting individual equipment: + Pedidos: · Recepciones: · Recepciones pendientes de individualización: + + + Committed purchases + Compras comprometidas + + + Still to receive + Pendiente de recibir + + + Gross deliveries + Entregas brutas + + + Declared supplier returns + Devoluciones a proveedores declaradas + + + This organization-wide overview selects orders created in the date window, independently of equipment, site or customer filters. Deliveries and supplier returns cover the selected orders' entire histories. These purchase amounts are separate from consumed-piece costs. + Este resumen de la organización selecciona los pedidos creados en el periodo, independientemente de los filtros de equipo, centro o cliente. Las entregas y devoluciones a proveedores abarcan todo el historial de los pedidos seleccionados. Estos importes de compra son distintos del coste de las piezas consumidas. + + + Organization procurement overview + Resumen de compras de la organización + + + Original source dossiers equal the selected allocations plus other destinations excluded by the equipment scope. General unallocated facts stay visible. + Los importes de los expedientes de origen corresponden a las asignaciones seleccionadas y a los otros destinos excluidos por el ámbito de equipos. Los datos generales sin asignar siguen visibles. + + + Other excluded allocations, current cost + Otras asignaciones excluidas, coste actual + + + Source dossiers, current cost + Expedientes de origen, coste actual + + + Allocation reconciliation + Conciliación de las asignaciones + + + Retry report + Reintentar el informe + + + These are the exact dossiers contributing to the selected allocation. Open their private cost dossier to compare current facts with the immutable closure. + Estos son los expedientes que contribuyen a la asignación seleccionada. Abra su expediente de costes privado para comparar los datos actuales con la instantánea inmutable del cierre. + + + Open financial dossier + Abrir el expediente financiero + + + Source financial dossiers + Expedientes financieros de origen + + + These totals cover all filtered destinations and unallocated contributions, including results on other pages. Budget and resource estimates remain separate. + Estos totales incluyen todos los destinos filtrados y las contribuciones sin asignar, incluidos los resultados de otras páginas. Los presupuestos y las previsiones de recursos siguen separados. + + + Filtered maintenance totals + Totales de mantenimiento filtrados + + + Whole-dossier budgets, general expenses and unresolved identities remain here. They are counted once in the filtered totals and are never copied onto every equipment. + Los presupuestos globales de los expedientes, los gastos generales y las identidades sin resolver permanecen aquí. Se cuentan una sola vez en los totales filtrados y nunca se copian en cada equipo. + + + Unallocated contributions + Contribuciones sin asignar + + + Economic pilotage + Control económico + + + The calendar is locked while an occurrence is open. You can still change the operation name. + El calendario está bloqueado mientras haya una ocurrencia abierta. Puede seguir cambiando el nombre de la operación. + + + Search + Buscar + + + Remove + Eliminar + + + Customer status + Estado del cliente + + + Archived customers can receive references for historical exports. + Los clientes archivados pueden recibir referencias para las exportaciones históricas. + + The previous declaration has no confirmed result. Retry it unchanged before creating another.La declaración anterior no tiene un resultado confirmado. Reinténtela sin cambios antes de crear otra. + Could not recover previous declarationsNo se pudieron recuperar las declaraciones anteriores + New declarations stay unavailable until recovery succeeds.Las nuevas declaraciones seguirán sin estar disponibles hasta que se complete la recuperación. + Retry recoveryReintentar la recuperación + Wait for the current declaration to finish before leaving this page.Espere a que termine la declaración en curso antes de salir de esta página. diff --git a/src/locale/messages.fr.xlf b/src/locale/messages.fr.xlf index b4d983727..df498d9b5 100644 --- a/src/locale/messages.fr.xlf +++ b/src/locale/messages.fr.xlf @@ -1991,8 +1991,8 @@ Marque / modèle - Maintenance - Maintenance + Controls + Contrôles Status @@ -3447,8 +3447,8 @@ Envoi en cours… - Only the action is required; target and assignee can be set now or during execution. - Seule l’action est requise ; la cible et l’assigné peuvent être définis maintenant ou pendant l’exécution. + Choose the action and, for maintenance, the equipment concerned. Assignment and estimates are optional. + Choisissez l’action et, pour la maintenance, l’équipement concerné. L’affectation et les estimations sont facultatives. Target resource @@ -15221,6 +15221,46 @@ Log time Saisir du temps + + Correction history is unavailable offline. Reconnect to load earlier versions. + L’historique des corrections n’est pas disponible hors ligne. Reconnectez-vous pour charger les versions antérieures. + + + Reconnect to review the current version of this saved correction. + Reconnectez-vous pour examiner la version actuelle de cette correction enregistrée. + + + Checking saved correction + Vérification de la correction enregistrée + + + This journal page has not been saved on this device. Reconnect to load it. + Cette page du journal n’est pas enregistrée sur cet appareil. Reconnectez-vous pour la charger. + + + Synchronize or review this pending change before loading correction history. + Synchronisez ou examinez cette modification en attente avant de charger l’historique des corrections. + + + Loading correction history + Chargement de l’historique des corrections + + + Load earlier versions + Charger les versions antérieures + + + Time journal pages + Pages du journal du temps + + + Page + Page + + + Saved entries: + Saisies enregistrées : + No journal has been saved on this device. Reconnect to load its history. Aucun journal n’est enregistré sur cet appareil. Reconnectez-vous pour charger l’historique. @@ -17981,6 +18021,3977 @@ Choose its type and name, then a compatible parent for buildings, floors, zones and areas. Sites stay at the root. Other details can be completed later. Choisissez son type et son nom, puis un parent compatible pour les bâtiments, étages, zones et secteurs. Les sites restent à la racine. Les autres informations pourront être complétées plus tard. + + Customers + Clients + + + Customers + Clients + + + Internal customer records and contacts for your sites. + Fiches clients et contacts internes pour vos sites. + + + Customers are managed online + La gestion des clients nécessite une connexion + + + Reconnect to update customer records. Your draft is preserved. + Reconnectez-vous pour modifier les clients. Votre brouillon est conservé. + + + Search customers + Rechercher des clients + + + Customer status + Statut des clients + + + Active + Actifs + + + Archived + Archivés + + + Customers could not be loaded + Impossible de charger les clients + + + Customer action failed + L’action sur le client a échoué + + + Actions + Actions + + + Restore + Restaurer + + + Archive + Archiver + + + No customers in this view + Aucun client dans cette vue + + + Change your search or create a customer. Sites can also be managed without a customer. + Modifiez votre recherche ou créez un client. Les sites peuvent aussi être gérés sans client. + + + New customer + Nouveau client + + + Edit customer + Modifier le client + + + Keep the customer's identity and contacts together for its sites. + Regroupez l’identité et les contacts du client pour ses sites. + + + Refresh the latest version and keep my draft + Actualiser la dernière version et conserver mon brouillon + + + Customer information + Informations du client + + + Name + Nom + + + Enter the customer name. + Saisissez le nom du client. + + + Internal reference + Référence interne + + + Email + Email + + + Enter a valid email address. + Saisissez une adresse email valide. + + + Telephone + Téléphone + + + Role + Fonction + + + Contacts + Contacts + + + Contact + Contact + + + Enter the contact name. + Saisissez le nom du contact. + + + Keep at most 50 contacts per customer. + Conservez au maximum 50 contacts par client. + + + Contacts are internal records. They do not receive access to FireGuard. + Les contacts sont des fiches internes. Ils ne reçoivent aucun accès à FireGuard. + + + Add contact + Ajouter un contact + + + Remove contact + Retirer le contact + + + Customer could not be saved + Impossible d’enregistrer le client + + + Save customer + Enregistrer le client + + + Create customer + Créer le client + + + Choose a customer + Choisir un client + + + No customer + Sans client + + + (archived) + (archivé) + + + No matching customers + Aucun client correspondant + + + (archived) + (archivé) + + + Loading customer + Chargement du client + + + Customer + Client + + + Optional. Assign a customer to this root site, or manage it directly as an operator. + Facultatif. Affectez un client à ce site racine, ou gérez-le directement comme exploitant. + + + Use at most 160 characters. + Utilisez au maximum 160 caractères. + + + Use at most 80 characters. + Utilisez au maximum 80 caractères. + + + Use at most 40 characters. + Utilisez au maximum 40 caractères. + + + Operation plans + Plans d’opérations + + + Control + Contrôle + + + Maintenance + Entretien + + + Historical cadence retained + Périodicité historique conservée + + + Anchored calendar + Calendrier ancré + + + Prepared + Préparé + + + View equipment + Voir l’équipement + + + Next due: + Prochaine échéance : + + + First deadline to configure — no validated control recorded. + Première échéance à configurer — aucun contrôle validé enregistré. + + + Original deadline: · attempt + Échéance d’origine : · tentative + + + Open existing intervention + Ouvrir l’intervention existante + + + Preview dates + Voir les prochaines dates + + + Configure + Configurer + + + Prepare intervention + Préparer l’intervention + + + Start a new attempt + Démarrer une nouvelle tentative + + + Historical schedules + Échéanciers historiques + + + Prepare an operation plan + Préparer un plan d’opération + + + Keep controls and maintenance independent. Preparing work never marks an operation as completed. + Suivez les contrôles et les entretiens indépendamment. Préparer des travaux ne valide jamais une opération. + + + Scheduling authority unavailable + Moteur de planification indisponible + + + Historical scheduling remains active + La planification historique reste active + + + Prepare the historical plans and review their dates before switching. Existing intervals and recorded history are retained. Only one scheduling engine generates work. + Préparez les plans historiques et vérifiez leurs dates avant la bascule. Les périodicités et l’historique enregistrés sont conservés. Un seul moteur de planification génère les travaux. + + + Prepare historical plans + Préparer les plans historiques + + + Review scheduling switch + Préparer la bascule de planification + + + Operation configuration + Configuration de l’opération + + + Close preview + Fermer l’aperçu + + + The server calculates these next dates. Review them before activating this operation. + Ces prochaines dates sont calculées par le serveur. Vérifiez-les avant d’activer cette opération. + + + Next three deadlines + Trois prochaines échéances + + + Configure the first deadline to preview this historical operation. + Configurez la première échéance pour afficher l’aperçu de cette opération historique. + + + Activate this operation + Activer cette opération + + + Pause this operation + Suspendre cette opération + + + Intervention could not be prepared + L’intervention n’a pas pu être préparée + + + Open intervention # + Ouvrir l’intervention n° + + + Operation deadlines + Échéances des opérations + + + Controls + Contrôles + + + Search operation plans + Rechercher des plans d’opérations + + + Operation plans could not be loaded + Les plans d’opérations n’ont pas pu être chargés + + + No operation plans match this view + Aucun plan d’opération ne correspond à cette vue + + + Adjust your search or prepare an equipment operation. + Adaptez votre recherche ou préparez une opération sur un équipement. + + + Activate operation plans for this organization? + Activer les plans d’opérations pour cette organisation ? + + + This switches work generation from historical schedules to the prepared operation plans. The server checks existing campaigns and blocks ambiguous links. Historical intervals and results remain available. + La génération des travaux passera des échéanciers historiques aux plans d’opérations préparés. Le serveur vérifie les campagnes existantes et bloque les rapprochements ambigus. Les périodicités et résultats historiques restent disponibles. + + + Activate operation plans + Activer les plans d’opérations + + + Operation plans + Plans d’opérations + + + Equipment + Équipement + + + No matching equipment loaded. + Aucun équipement correspondant chargé. + + + Loading equipment… + Chargement des équipements… + + + Previous equipment + Équipements précédents + + + Next equipment + Équipements suivants + + + Operation name + Nom de l’opération + + + Operation + Opération + + + Every + Tous les + + + Calendar unit + Unité calendaire + + + Choose a unit + Choisir une unité + + + Anchor date + Date d’ancrage + + + First due date + Première échéance + + + Optional for a new plan; defaults to the anchor date. Historical equipment without a recorded control needs an explicit first deadline. + Facultative pour un nouveau plan ; la date d’ancrage est utilisée par défaut. Un équipement historique sans contrôle enregistré nécessite une première échéance explicite. + + + Choose your organization's frequency. A monthly date uses the last day of shorter months and retains its original anchor. This configuration does not certify regulatory compliance. + Choisissez la périodicité de votre organisation. Une date mensuelle utilise le dernier jour des mois plus courts et conserve son ancrage d’origine. Cette configuration ne certifie pas la conformité réglementaire. + + + Save and preview dates + Enregistrer et voir les prochaines dates + + + Select an equipment. + Sélectionnez un équipement. + + + Enter an operation name. + Saisissez un nom d’opération. + + + Use at most 160 characters. + Utilisez au maximum 160 caractères. + + + Choose a calendar unit. + Choisissez une unité calendaire. + + + Enter a positive interval. + Saisissez un intervalle positif. + + + Enter a whole number. + Saisissez un nombre entier. + + + Choose an anchor date. + Choisissez une date d’ancrage. + + + Days + Jours + + + Weeks + Semaines + + + Months + Mois + + + Years + Années + + + The plan could not be saved. + Le plan n’a pas pu être enregistré. + + + Equipment name + Nom de l’équipement + + + Asset reference + Repère patrimonial + + + Unique within your organization when supplied. + Unique dans votre organisation lorsqu’il est renseigné. + + + Declared criticality + Criticité déclarée + + + Not specified + Non renseignée + + + Low + Faible + + + Medium + Moyenne + + + High + Élevée + + + Critical + Critique + + + Technical characteristics + Caractéristiques techniques + + + Declared information about this equipment; it does not determine control obligations. + Informations déclarées sur cet équipement ; elles ne déterminent pas ses obligations de contrôle. + + + Characteristic + Caractéristique + + + Value + Valeur + + + Unit + Unité + + + Add a characteristic + Ajouter une caractéristique + + + Enter a characteristic name. + Renseignez un nom de caractéristique. + + + Enter a value. + Renseignez une valeur. + + + Characteristic names must be unique. + Les noms des caractéristiques doivent être uniques. + + + Declared operational status + État opérationnel déclaré + + + Control follow-up + Suivi des contrôles + + + Open anomalies + Anomalies ouvertes + + + Inspection access required + Accès aux inspections requis + + + Unavailable + Indisponible + + + Open the last control + Ouvrir le dernier contrôle + + + Report a defect + Signaler un défaut + + + Replace equipment + Remplacer l’équipement + + + Replacement history + Historique des remplacements + + + Open the replaced equipment + Ouvrir l’équipement remplacé + + + Open the successor equipment + Ouvrir le nouvel équipement + + + From reserve + Depuis la réserve + + + New equipment + Nouvel équipement + + + Successor equipment + Équipement successeur + + + Search reserve equipment + Rechercher un équipement en réserve + + + Select the successor equipment. + Sélectionnez l’équipement successeur. + + + Confirm replacement + Confirmer le remplacement + + + The response was lost. Retry the same operation to recover its result without creating a second replacement. + La réponse a été perdue. Réessayez la même opération pour retrouver son résultat sans créer un second remplacement. + + + Confirm the replacement + Confirmer le remplacement + + + will be retired. Its history remains accessible and the successor inherits its site and position. + sera retiré. Son historique restera accessible et le successeur héritera de son site et de sa position. + + + will be retired permanently. Its dossier and history remain accessible through the successor. + sera définitivement retiré. Son dossier et son historique resteront accessibles depuis le successeur. + + + Controls and maintenance + Contrôles et maintenance + + + Prepare a control + Préparer un contrôle + + + Prepare maintenance + Préparer un entretien + + + Organize a repair + Organiser une réparation + + + Intervention access required + Accès aux interventions requis + + + Open work is unavailable. Retry before preparing a new intervention. + Les travaux ouverts sont indisponibles. Réessayez avant de préparer une nouvelle intervention. + + + View maintenance plans + Voir les plans d’entretien + + + Equipment park + Parc + + + Equipment coverage + Périmètre des équipements + + + Fire equipment + Parc incendie + + + All equipment + Tous les équipements + + + Show the complete park + Afficher tout le parc + + + All sites + Tous les sites + + + Site + Site + + + Park actions + Actions du parc + + + Unavailable equipment + Équipements indisponibles + + + Controls to prepare + Contrôles à préparer + + + Anomalies to address + Anomalies à traiter + + + Some park counts could not be loaded + Certains compteurs du parc n’ont pas pu être chargés + + + No unresolved anomalies in this park scope + Aucune anomalie non résolue dans ce périmètre + + + Open the source inspection + Ouvrir le contrôle d’origine + + + Open source inspection for + Ouvrir le contrôle d’origine pour + + + Equipment types + Types d’équipements + + + Manage the names and families available in your organization's inventory. + Gérez les noms et les familles disponibles dans le parc de votre organisation. + + + New equipment type + Nouveau type d’équipement + + + Edit equipment type + Modifier le type d’équipement + + + Equipment types are managed online + Les types d’équipements se gèrent en ligne + + + Reconnect to save changes. Your draft is preserved. + Reconnectez-vous pour enregistrer les modifications. Votre saisie est conservée. + + + Equipment type status + État du type d’équipement + + + Active + Actifs + + + Archived + Archivés + + + Equipment types could not be loaded + Impossible de charger les types d’équipements + + + Equipment type could not be updated + Impossible de modifier le type d’équipement + + + Reload catalogue + Recharger le catalogue + + + Actions + Actions + + + Restore + Restaurer + + + Archive + Archiver + + + No equipment types in this view + Aucun type d’équipement dans cette vue + + + Choose another view or create an equipment type. Archived types remain available in equipment histories. + Choisissez une autre vue ou créez un type d’équipement. Les types archivés restent disponibles dans les historiques des équipements. + + + Keep stable codes for equipment records and choose the family used to filter your inventory. + Conservez des codes stables pour vos équipements et choisissez la famille utilisée pour filtrer le parc. + + + This type has changed + Ce type a été modifié + + + Reload the latest revision, compare its name and family with your draft, then save again. Your entered values are preserved. + Rechargez la dernière révision, comparez son nom et sa famille avec votre saisie, puis enregistrez à nouveau. Vos valeurs sont conservées. + + + Review latest revision + Consulter la dernière révision + + + Reviewed server values: + Valeurs serveur consultées : + + + Fire + Incendie + + + Safety + Sécurité + + + Other + Autres + + + Equipment type + Type d’équipement + + + Permanent code + Code permanent + + + The code cannot be changed after creation. + Le code ne peut plus être modifié après la création. + + + Use 1–32 lowercase letters, digits or underscores, starting with a letter. + Utilisez 1 à 32 lettres minuscules, chiffres ou traits de soulignement, en commençant par une lettre. + + + Name + Nom + + + Enter an equipment type name. + Saisissez le nom du type d’équipement. + + + Use at most 100 characters for the name. + Le nom doit contenir au maximum 100 caractères. + + + Family + Famille + + + Choose an equipment family. + Choisissez une famille d’équipements. + + + Save type + Enregistrer le type + + + Create type + Créer le type + + + Equipment type could not be saved + Impossible d’enregistrer le type d’équipement + + + Preventive maintenance + Maintenance préventive + + + Corrective maintenance + Maintenance corrective + + + Maintenance + Entretien + + + Repair + Réparation + + + Replacement + Remplacement + + + Choose the equipment this work concerns. + Choisissez l’équipement concerné par ces travaux. + + + Select the equipment to keep its maintenance history traceable. + Sélectionnez l’équipement pour conserver la traçabilité de son entretien. + + + Select equipment + Sélectionner un équipement + + + Enter when the work was performed. + Indiquez quand les travaux ont été réalisés. + + + Enter a valid date and time in the displayed timezone. + Saisissez une date et une heure valides dans le fuseau affiché. + + + Choose the result of this work. + Choisissez le résultat de ces travaux. + + + Choose a result appropriate for this action. + Choisissez un résultat adapté à cette action. + + + Describe the work actually performed. + Décrivez les travaux réellement effectués. + + + Keep the work description within 10,000 characters. + Limitez la description des travaux à 10 000 caractères. + + + The execution result could not be saved. + Le résultat d’exécution n’a pas pu être enregistré. + + + Performed at + Réalisé le + + + Date and time in . + Date et heure dans le fuseau . + + + Result + Résultat + + + Successful + Réussi + + + Performed + Réalisé + + + Unsuccessful — work still required + Échec — travaux toujours nécessaires + + + Work performed + Travaux effectués + + + The result is recorded now and validated when the intervention is published. An unsuccessful result keeps the task open. + Le résultat est enregistré maintenant et validé lors de la publication de l’intervention. Un échec laisse la tâche ouverte. + + + Record result + Enregistrer le résultat + + + Record actual work + Enregistrer les travaux réalisés + + + Performed + Réalisé + + + Validated + Validé + + + Pending review + En attente de revue + + + Previous attempts + Tentatives précédentes + + + This task can no longer be executed in the current workflow. Your draft remains available. + Cette tâche ne peut plus être exécutée dans le workflow actuel. Votre brouillon reste disponible. + + + Export validated report + Exporter le rapport validé + + + Export draft report + Exporter le rapport provisoire + + + Organization profile + Profil de l'organisation + + + Site operator + Exploitant de sites + + + Service provider + Prestataire + + + Choose how you manage your fire-safety equipment. Access remains determined by your organization's permissions. + Choisissez votre mode de gestion du parc incendie. Les permissions de votre organisation déterminent vos droits. + + + Maintenance follow-up + Suivi de l’entretien + + + This type is no longer available for new equipment. Choose an active type. + Ce type n’est plus disponible pour les nouveaux équipements. Choisissez un type actif. + + + Equipment families + Familles d’équipements + + + Fire equipment + Équipements incendie + + + All equipment + Tous les équipements + + + No active fire equipment types. Choose All equipment to view the other available types. + Aucun type d’équipement incendie actif. Choisissez Tous les équipements pour afficher les autres types disponibles. + + + No active equipment types + Aucun type d’équipement actif + + + An active type is needed to register equipment. Refresh the available choices after the organization's types have been updated. + Un type actif est nécessaire pour enregistrer un équipement. Actualisez les choix disponibles après la mise à jour des types de l’organisation. + + + Next control + Prochain contrôle + + + Next maintenance + Prochain entretien + + + Replacement equipment + Équipement de remplacement + + + Checking the equipment replacement… + Vérification du remplacement de l’équipement… + + + This published equipment is linked to the retired original. + Cet équipement publié est lié à l’ancien équipement retiré. + + + The replacement could not be checked. Keep your draft and refresh when the equipment dossier is available. + Le remplacement n’a pas pu être vérifié. Conservez votre brouillon et actualisez lorsque le dossier équipement est disponible. + + + Confirm the replacement in the equipment dossier, then refresh its successor. + Confirmez le remplacement dans le dossier équipement, puis actualisez son successeur. + + + Open equipment dossier + Ouvrir le dossier équipement + + + Replacement equipment: + Équipement de remplacement : + + + Equipment types could not be loaded + Les types d'équipements n'ont pas pu être chargés + + + Repair requests + Demandes de réparation + + + Maintenance request + Demande de maintenance + + + Requested + À qualifier + + + Qualified + Qualifiée + + + Rejected + Refusée + + + Cancelled + Annulée + + + Converted into work + Travaux organisés + + + Low + Faible + + + Normal + Normale + + + High + Haute + + + Urgent + Urgente + + + Qualify the maintenance need before explicitly organizing corrective work. + Qualifiez le besoin de maintenance avant d'organiser explicitement les travaux correctifs. + + + The request has changed. Review its latest version before applying your draft. + La demande a changé. Examinez sa dernière version avant d'appliquer votre saisie. + + + Review latest version + Examiner la dernière version + + + The current request state no longer allows this action. + L'état actuel de la demande ne permet plus cette action. + + + Use reviewed version and keep my draft + Utiliser la version examinée et conserver ma saisie + + + Saved maintenance operations could not be restored. Retry before starting another action. + Les opérations de maintenance enregistrées n’ont pas pu être restaurées. Réessayez avant de commencer une autre action. + + + This conversion could not be saved on this device. Your draft has been kept. Retry to send it. + Cette conversion n’a pas pu être enregistrée sur cet appareil. Votre brouillon a été conservé. Réessayez pour l’envoyer. + + + The work link has not been confirmed. Retry this conversion before choosing another action. + Le lien avec les travaux n'est pas confirmé. Réessayez cette conversion avant de choisir une autre action. + + + Retry the same conversion + Réessayer la même conversion + + + New maintenance request + Nouvelle demande de maintenance + + + Edit maintenance request + Modifier la demande de maintenance + + + Qualify maintenance request + Qualifier la demande de maintenance + + + Reject request + Refuser la demande + + + Cancel request + Annuler la demande + + + Organize corrective work + Organiser les travaux correctifs + + + Maintenance requests + Demandes de maintenance + + + Requested + Créée + + + Qualify request + Qualifier la demande + + + Maintenance target + Cible de maintenance + + + Equipment + Équipement + + + Open equipment dossier + Ouvrir le dossier de l'équipement + + + Site + Site + + + Open site dossier + Ouvrir le dossier du site + + + Open source inspection + Ouvrir le contrôle d'origine + + + Qualification + Qualification + + + Decision reason + Motif de la décision + + + Corrective work + Travaux correctifs + + + Open corrective intervention + Ouvrir l'intervention corrective + + + This request could not be opened + Impossible d'ouvrir cette demande + + + Describe maintenance needs, qualify their target, then organize corrective work. + Décrivez les besoins de maintenance, qualifiez leur cible, puis organisez les travaux correctifs. + + + Maintenance request submitted: + Demande de maintenance envoyée : + + + You are offline. Your open draft is kept until you can submit it. + Vous êtes hors ligne. Votre saisie ouverte est conservée jusqu'à ce que vous puissiez l'envoyer. + + + Search maintenance requests + Rechercher des demandes de maintenance + + + Request status + État de la demande + + + All statuses + Tous les états + + + Requests could not be loaded + Impossible de charger les demandes + + + No maintenance requests + Aucune demande de maintenance + + + The current target and filters have no requests to display. + La cible et les filtres actuels ne contiennent aucune demande à afficher. + + + Clear filters + Effacer les filtres + + + Title + Titre + + + Priority + Priorité + + + Qualification note + Note de qualification + + + Link an existing corrective task or create an intervention for this qualified request. + Reliez une tâche corrective existante ou créez une intervention pour cette demande qualifiée. + + + Corrective work + Travaux correctifs + + + Create a corrective intervention + Créer une intervention corrective + + + Your permissions do not allow viewing existing interventions. + Vos droits ne permettent pas de consulter les interventions existantes. + + + Loading open work + Chargement des travaux ouverts + + + Use at most 10000 characters. + Utilisez au plus 10 000 caractères. + + + Use at most 2000 characters. + Utilisez au plus 2 000 caractères. + + + Explain this decision. + Expliquez cette décision. + + + Identify the equipment requiring maintenance. + Identifiez l'équipement à entretenir. + + + Choose current corrective work. + Choisissez des travaux correctifs encore ouverts. + + + Open corrective work + Travaux correctifs ouverts + + + Confirm qualification + Confirmer la qualification + + + Link this corrective work + Relier ces travaux correctifs + + + Create corrective intervention + Créer l'intervention corrective + + + A site-only request can be qualified once its equipment is identified. + Une demande limitée à un site peut être qualifiée une fois son équipement identifié. + + + Maintenance need + Besoin de maintenance + + + Describe the request with a title. + Décrivez la demande avec un titre. + + + Describe the maintenance need. + Décrivez le besoin de maintenance. + + + Use at most 160 characters. + Utilisez au plus 160 caractères. + + + Choose equipment or a site. + Choisissez un équipement ou un site. + + + Save request + Enregistrer la demande + + + Submit maintenance request + Envoyer la demande de maintenance + + + No equipment selected + Aucun équipement sélectionné + + + Unavailable for a new request + Indisponible pour une nouvelle demande + + + Equipment selection requires permission to read the park. + La sélection d'un équipement nécessite le droit de consulter le parc. + + + Choose equipment + Choisir un équipement + + + Site selection requires permission to read sites. + La sélection d'un site nécessite le droit de consulter les sites. + + + Parts and stock + Pièces et stocks + + + Purchasing and receipts + Achats et réceptions + + + Maintenance costs + Coûts de maintenance + + + Request a repair + Demander une réparation + + + Choose a part + Choisir une pièce + + + Choose a warehouse + Choisir un magasin + + + (archived) + (archivé) + + + No matching references + Aucune référence correspondante + + + Loading choices + Chargement des choix + + + Choices could not be loaded + Impossible de charger les choix + + + Selected reference could not be loaded + Impossible de charger la référence sélectionnée + + + Choose an active part. + Choisissez une pièce active. + + + Choose an active warehouse. + Choisissez un magasin actif. + + + Enter the quantity used. + Indiquez la quantité utilisée. + + + Enter a positive decimal with up to 18 whole digits and 6 decimal places, using a dot. + Indiquez un nombre décimal positif avec au maximum 18 chiffres entiers et 6 décimales, en utilisant un point. + + + Declare parts used + Déclarer les pièces utilisées + + + Record the physical quantity used. Stock is confirmed after synchronization. + Enregistrez la quantité réellement utilisée. Le stock est confirmé après synchronisation. + + + Part + Pièce + + + Warehouse + Magasin + + + Quantity used + Quantité utilisée + + + Declaration could not be queued + Impossible de mettre la déclaration en attente + + + Declare consumption + Déclarer la consommation + + + The declaration is retained here until it is safely queued. Retry keeps the same recorded facts. + La déclaration est conservée ici jusqu’à son enregistrement dans la file locale. Réessayer conserve les mêmes données. + + + Consumption history + Historique des consommations + + + These states describe stock recording. They do not indicate completed work or a validated dossier. + Ces états décrivent l’enregistrement du stock. Ils n’indiquent ni un travail terminé ni un dossier validé. + + + No consumption has been declared for this work. + Aucune consommation n’a été déclarée pour ce travail. + + + Stock debit confirmed + Sortie de stock confirmée + + + Received — reconciliation needed + Reçue — rapprochement nécessaire + + + No stock balance is recorded for this source. + Aucun solde de stock n’est enregistré pour cette source. + + + The part or warehouse is archived. + La pièce ou le magasin est archivé. + + + Recorded stock is insufficient for the full quantity. + Le stock enregistré est insuffisant pour la quantité entière. + + + Sending — stock not confirmed + Envoi en cours — stock non confirmé + + + Queued locally — retry needed + En attente locale — nouvelle tentative nécessaire + + + Queued locally — awaiting synchronization + En attente locale — synchronisation à venir + + + Recorded after publication; the published dossier remains unchanged. + Enregistrée après publication ; le dossier publié reste inchangé. + + + Parts used + Pièces utilisées + + + Hide declarations + Masquer les déclarations + + + View and declare parts + Consulter et déclarer les pièces + + + Declaration not yet saved on this device + Déclaration pas encore enregistrée sur cet appareil + + + Keep this page open. Retry the same declaration before leaving or publishing. + Gardez cette page ouverte. Réessayez d’enregistrer la même déclaration avant de quitter ou de publier. + + + Stock preparation could not finish + La préparation du stock n’a pas pu aboutir + + + Retry preparation + Réessayer la préparation + + + Connect and prepare this intervention’s parts and warehouses before declaring them offline. + Connectez-vous et préparez les pièces et magasins de cette intervention avant de les déclarer hors ligne. + + + References saved on this device. Stock availability is confirmed when declarations reach the server. + Références enregistrées sur cet appareil. La disponibilité du stock est confirmée lorsque les déclarations parviennent au serveur. + + + Preparing stock references and declarations… + Préparation des références de stock et des déclarations… + + + Used for + Utilisées pour + + + Whole intervention + Ensemble de l’intervention + + + Selecting a task links the parts used to that work and its equipment. + Choisir une tâche rattache les pièces utilisées aux travaux et à leur équipement. + + + Work item + Tâche + + + A declaration added after publication updates current stock records. The published dossier keeps its original data. + Une déclaration ajoutée après publication actualise le suivi courant du stock. Le dossier publié conserve ses données d’origine. + + + View internal maintenance costs + Consulter les coûts internes de maintenance + + + Save the submitted parts declaration on this device before publishing. Retry the declaration in Parts used. + Enregistrez la déclaration de pièces sur cet appareil avant de publier. Réessayez depuis Pièces utilisées. + + + Save this intervention on the device before preparing its stock references. + Enregistrez cette intervention sur l’appareil avant de préparer ses références de stock. + + + This intervention must be saved on this device before a consumption can be queued. Retry preparing the workspace. + L’intervention doit être enregistrée sur cet appareil avant de mettre une consommation en attente. Réessayez la préparation du dossier. + + + This declaration identifier already belongs to another physical fact. Keep the original declaration and review it before continuing. + L’identifiant de cette déclaration correspond déjà à un autre fait physique. Conservez la déclaration d’origine et vérifiez-la avant de poursuivre. + + + Review the conflicted declaration before retrying it. + Vérifiez la déclaration en conflit avant de la réessayer. + + + The declaration history could not be verified for this intervention. Refresh before continuing. + L’historique des déclarations n’a pas pu être vérifié pour cette intervention. Actualisez-le avant de poursuivre. + + + The stock references changed or could not be downloaded completely. Retry preparation. + Les références de stock ont changé ou n’ont pas pu être téléchargées entièrement. Réessayez la préparation. + + + The server received this declaration, but its receipt could not be saved on this device. Keep the queued declaration and synchronize again. + Le serveur a reçu cette déclaration, mais son reçu n’a pas pu être enregistré sur cet appareil. Conservez la déclaration en attente et synchronisez de nouveau. + + + Parts used + Pièces utilisées + + + Inventory + Stocks + + + Stock + Stock + + + Parts and consumables + Pièces et consommables + + + Warehouses + Magasins + + + Consumption declarations + Déclarations de consommation + + + Movement history + Historique des mouvements + + + Enter a permanent code. + Saisissez un code permanent. + + + Enter a name. + Saisissez un nom. + + + Enter a stock unit. + Saisissez une unité de stock. + + + Stock reference + Référence de stock + + + Permanent code + Code permanent + + + Codes and part categories cannot be changed after creation. + Les codes et catégories de pièces ne peuvent plus être modifiés après création. + + + Name + Nom + + + Stock unit + Unité de stock + + + For example: piece, metre or litre. Quantities use this unit. + Par exemple : pièce, mètre ou litre. Les quantités utilisent cette unité. + + + Category + Catégorie + + + Part + Pièce + + + Consumable + Consommable + + + The reference could not be saved + La référence n’a pas pu être enregistrée + + + Warehouse + Magasin + + + Quantity + Quantité + + + Enter a non-zero decimal with up to 18 whole digits and 6 decimal places, using a dot. + Saisissez un nombre décimal non nul avec au plus 18 chiffres entiers et 6 décimales, en utilisant un point. + + + Explain why this movement is needed. + Expliquez pourquoi ce mouvement est nécessaire. + + + Movement details + Détails du mouvement + + + Use a negative quantity to remove stock, or a positive quantity to add it. + Utilisez une quantité négative pour retirer du stock, ou positive pour en ajouter. + + + Enter the quantity physically returned from this consumption. + Saisissez la quantité physiquement retournée de cette consommation. + + + Reason + Motif + + + The movement could not be confirmed + Le mouvement n’a pas pu être confirmé + + + Record movement + Enregistrer le mouvement + + + Received after publication + Reçu après publication + + + Open intervention + Ouvrir l’intervention + + + Restore + Restaurer + + + Archive + Archiver + + + Reconcile stock + Rapprocher le stock + + + Record return + Enregistrer un retour + + + No stock records in this view + Aucun enregistrement de stock dans cette vue + + + Change your filters or add a reference. Archived references remain readable in the history. + Modifiez vos filtres ou ajoutez une référence. Les références archivées restent consultables dans l’historique. + + + Stock pages + Pages des stocks + + + Previous + Précédent + + + Next + Suivant + + + Page of + Page sur + + + Receipt + Réception + + + Supplier return + Retour fournisseur + + + Consumption + Consommation + + + Return + Retour + + + Correction + Correction + + + No stock balance is available for this part and warehouse. + Aucun solde de stock n’est disponible pour cette pièce et ce magasin. + + + This part or warehouse is archived. Review the retained declaration before reconciling. + Cette pièce ou ce magasin est archivé. Vérifiez la déclaration conservée avant le rapprochement. + + + The declared quantity exceeds available stock. The full declaration is retained without a partial debit. + La quantité déclarée dépasse le stock disponible. La déclaration entière est conservée sans sortie partielle. + + + Part or consumable + Pièce ou consommable + + + Warehouse + Magasin + + + Record stock correction + Enregistrer une correction de stock + + + Archived + Archivé + + + Active + Actif + + + Confirmed + Confirmé + + + Received — to reconcile + Reçu — à rapprocher + + + Track parts and consumables separately from individual equipment. Confirmed movements update stock; retained declarations stay visible until reconciled. + Suivez les pièces et consommables séparément des équipements individualisés. Les mouvements confirmés mettent à jour le stock ; les déclarations conservées restent visibles jusqu’au rapprochement. + + + New part or consumable + Nouvelle pièce ou consommable + + + New warehouse + Nouveau magasin + + + Inventory sections + Rubriques des stocks + + + Stock confirmation requires a connection + La confirmation du stock nécessite une connexion + + + Reconnect to read current stock or confirm a movement. Unacknowledged movements remain saved on this device. + Reconnectez-vous pour consulter le stock actuel ou confirmer un mouvement. Les mouvements non confirmés restent enregistrés sur cet appareil. + + + Saved movements could not be read + Les mouvements enregistrés n’ont pas pu être lus + + + Movements awaiting confirmation + Mouvements en attente de confirmation + + + The response may have been lost. Retry the saved movement to obtain confirmation with the same identity. + La réponse a pu être perdue. Réessayez le mouvement enregistré pour obtenir sa confirmation avec le même identifiant. + + + Retry saved movement + Réessayer le mouvement enregistré + + + Search by code or name + Rechercher par code ou nom + + + Reference status + État de la référence + + + Declaration status + État de la déclaration + + + All + Tous + + + Apply filters + Appliquer les filtres + + + A declaration is a retained physical fact + Une déclaration conserve un fait physique + + + A pending declaration does not debit part of the stock or complete the work. Reconciliation confirms the entire quantity; dossier validation remains an intervention decision. + Une déclaration en attente ne sort pas une partie du stock et ne termine pas les travaux. Le rapprochement confirme toute la quantité ; la validation du dossier relève de l’intervention. + + + Stock records could not be loaded + Les enregistrements de stock n’ont pas pu être chargés + + + The operation could not be confirmed + L’opération n’a pas pu être confirmée + + + Preserve stable references and record motivated stock movements without changing history. + Conservez des références stables et enregistrez des mouvements de stock motivés sans modifier l’historique. + + + This movement keeps one identity + Ce mouvement conserve un seul identifiant + + + Keep this identity when retrying. The stock is confirmed only by the server response. + Conservez cet identifiant lors des nouvelles tentatives. Seule la réponse du serveur confirme le stock. + + + Close + Fermer + + + Unknown + Inconnu + + + Choose the actual UTC offset + Choisissez le décalage UTC réel + + + UTC offset for this repeated hour + Décalage UTC pour cette heure répétée + + + The same local time occurred twice because the clocks changed. Select the UTC offset when the expense actually occurred. + Cette heure locale s’est produite deux fois lors du changement d’heure. Choisissez le décalage UTC au moment où la dépense a réellement eu lieu. + + + This local hour occurs twice. Choose the UTC offset of the actual expense. + Cette heure locale se produit deux fois. Choisissez le décalage UTC de la dépense réelle. + + + Enter a valid actual date that is not in the future. + Saisissez une date réelle valide qui ne soit pas dans le futur. + + + Adjust the original expense + Ajuster la dépense d’origine + + + Exact amount + Montant exact + + + Enter the exact expense amount. + Saisissez le montant exact de la dépense. + + + Cancel adjustment + Annuler l’ajustement + + + Actual date and time + Date et heure réelles + + + Enter the actual expense date and time. + Saisissez la date et l’heure réelles de la dépense. + + + Description or correction reason + Description ou motif de correction + + + Enter an exact decimal with at most six decimal places. A negative correction requires its original expense. + Saisissez un nombre décimal exact avec au plus six décimales. Une correction négative doit être liée à sa dépense d’origine. + + + Expenses and corrections are appended to the history. The closure snapshot remains unchanged. + Les dépenses et corrections sont ajoutées à l’historique. Les coûts figés à la clôture restent inchangés. + + + Describe the expense or explain its correction. + Décrivez la dépense ou expliquez sa correction. + + + Record expense + Enregistrer la dépense + + + Record adjustment + Enregistrer l’ajustement + + + Add an external expense + Ajouter une dépense externe + + + Actions + Actions + + + Adjust expense + Ajuster la dépense + + + Traceable adjustment + Ajustement traçable + + + Exact amount + Montant exact + + + Private maintenance cost contributions + Contributions aux coûts internes de maintenance + + + Occurred on + Date de réalisation + + + Contribution + Contribution + + + No cost contributions recorded + Aucune contribution aux coûts enregistrée + + + Recorded work time, material use and external expenses will appear here. + Les temps de travail, consommations de matériel et dépenses externes enregistrés apparaîtront ici. + + + Financial information could not be saved. + Les informations financières n’ont pas pu être enregistrées. + + + Financial information saved. + Informations financières enregistrées. + + + Enter a non-negative decimal with at most six decimal places. + Saisissez un nombre décimal positif ou nul avec au plus six décimales. + + + Enter whole minutes between zero and 100000000. + Saisissez un nombre entier de minutes entre zéro et 100000000. + + + External expense + Dépense externe + + + External service + Prestation externe + + + Materials + Matériel + + + Work time + Temps de travail + + + Browse interventions + Consulter les interventions + + + Captured on + Date d’enregistrement + + + Choose an intervention + Choisir une intervention + + + Open an intervention's financial dossier to prepare its budget and follow its costs. + Ouvrez le dossier financier d’une intervention pour préparer son budget et suivre ses coûts. + + + Current realized costs + Coûts réalisés actuels + + + Prepare budgets and follow internal operational costs. Commercial invoicing remains in your ERP. + Préparez les budgets et suivez les coûts opérationnels internes. La facturation commerciale reste dans votre ERP. + + + Intervention costs + Coûts de l’intervention + + + Costs recorded at closure + Coûts enregistrés à la clôture + + + Later expenses, returns and corrected time affect the current costs while this original snapshot stays unchanged. + Les dépenses ultérieures, retours et corrections de temps modifient les coûts actuels. L’état d’origine à la clôture reste inchangé. + + + Some valuations were unknown at closure. + Certaines valorisations étaient inconnues à la clôture. + + + Immutable snapshot + État figé à la clôture + + + The valuation is incomplete + La valorisation est incomplète + + + Some prices or hourly rates are unknown. Known contributions are shown separately and missing values are not treated as zero. + Certains prix ou taux horaires sont inconnus. Les contributions connues sont affichées séparément et les valeurs manquantes ne sont pas assimilées à zéro. + + + Known contributions + Contributions connues + + + Open intervention + Ouvrir l’intervention + + + Financial preparation is closed. The recorded budget is retained. + La préparation financière est clôturée. Le budget enregistré est conservé. + + + Private financial information + Informations financières internes + + + Financial information is available only in an authorized browser session. + Les informations financières sont accessibles uniquement dans une session de navigateur autorisée. + + + Could not load the financial dossier + Le dossier financier n’a pas pu être chargé + + + Reload + Recharger + + + Your original declaration is retained. Retry it unchanged to confirm whether it was saved. + Votre déclaration d’origine est conservée. Réessayez sans la modifier pour confirmer son enregistrement. + + + Retry the original declaration + Réessayer la déclaration d’origine + + + Currency and hourly rates + Devise et taux horaires + + + Complete realized total + Total réalisé complet + + + Confirmation is still needed + La confirmation est toujours attendue + + + Use the latest version for these edits + Appliquer ces modifications à la dernière version + + + Add estimated resource + Ajouter une ressource estimée + + + Estimated amount + Montant estimé + + + Planned budget + Budget prévu + + + The financial preparation has changed + La préparation financière a été modifiée + + + Your edits are retained. Compare them with the refreshed budget before using its latest version. + Vos modifications sont conservées. Comparez-les au budget actualisé avant d’utiliser sa dernière version. + + + Description + Description + + + Describe this estimated resource. + Décrivez cette ressource estimée. + + + Resource type + Type de ressource + + + Estimated minutes + Durée estimée en minutes + + + Quantity + Quantité + + + Read the latest budget for comparison + Consulter le dernier budget pour le comparer + + + Remove resource + Retirer la ressource + + + Estimated resource + Ressource estimée + + + Save financial preparation + Enregistrer la préparation financière + + + Financial preparation + Préparation financière + + + Unit or hourly amount + Montant unitaire ou horaire + + + Leave an estimate blank when it is unknown. Zero is a known value. + Laissez une estimation vide si elle est inconnue. Zéro est une valeur connue. + + + Add an hourly rate + Ajouter un taux horaire + + + Hourly amount + Montant horaire + + + per hour. Use a decimal point and up to six decimal places. + par heure. Utilisez un point décimal et au plus six décimales. + + + Enter a nonnegative amount with no more than six decimal places, using a decimal point. + Saisissez un montant positif ou nul, avec au plus six décimales et un point décimal. + + + Set the organization currency before adding a rate. + Définissez la devise de l’organisation avant d’ajouter un taux. + + + This rate applies from the selected calendar day. + Ce taux s’applique à partir de la date sélectionnée. + + + Choose a valid effective date. + Choisissez une date d’effet valide. + + + Effective from + Date d’effet + + + A new rate takes effect from its date. Earlier rates and recorded costs stay unchanged. + Un nouveau taux prend effet à sa date. Les taux antérieurs et les coûts enregistrés restent inchangés. + + + Member + Membre + + + Choose a member + Choisir un membre + + + Choose an available organization member. + Choisissez un membre disponible de l’organisation. + + + Member directory access is required to choose a member and add a rate. + L’accès à l’annuaire des membres est nécessaire pour choisir un membre et ajouter un taux. + + + No available members. Refresh the member directory before adding a rate. + Aucun membre disponible. Actualisez l’annuaire des membres avant d’ajouter un taux. + + + Loading members… + Chargement des membres… + + + Member details unavailable + Informations du membre indisponibles + + + Retry original submission + Réessayer l’envoi d’origine + + + Add rate + Ajouter le taux + + + Your rate draft is preserved. Try again when the request has settled. + Votre saisie de taux est conservée. Réessayez après la fin de la requête. + + + The rate could not be confirmed + Le taux n’a pas pu être confirmé + + + Keep this draft unchanged. Retry the original submission to confirm whether the rate was recorded. + Conservez cette saisie sans la modifier. Réessayez l’envoi d’origine pour confirmer si le taux a été enregistré. + + + The rate submission is awaiting confirmation + L’enregistrement du taux attend une confirmation + + + Currency code + Code de devise + + + Operational costs share one currency. It is locked after the first financial record. + Les coûts opérationnels utilisent une seule devise. Elle est verrouillée après le premier enregistrement financier. + + + Use a three-letter uppercase code, for example EUR. Currency is locked after the first financial record. + Utilisez un code de trois lettres majuscules, par exemple EUR. La devise est verrouillée après le premier enregistrement financier. + + + Currency could not be loaded + La devise n’a pas pu être chargée + + + Loading currency… + Chargement de la devise… + + + Locked + Verrouillée + + + Financial records already use this currency. It can no longer be changed. + Des enregistrements financiers utilisent déjà cette devise. Elle ne peut plus être modifiée. + + + Reload currency + Recharger la devise + + + Enter a three-letter uppercase currency code. + Saisissez un code de devise de trois lettres majuscules. + + + Save currency + Enregistrer la devise + + + Your currency draft is preserved. Try saving again. + Votre saisie de devise est conservée. Réessayez de l’enregistrer. + + + Currency could not be saved + La devise n’a pas pu être enregistrée + + + Organization currency + Devise de l’organisation + + + Current currency + Devise actuelle + + + No currency has been set. + Aucune devise n’a été définie. + + + Immutable hourly rates by member and effective date + Taux horaires immuables par membre et date d’effet + + + Rates are dated and immutable. Add a new rate when an amount changes. + Les taux sont datés et immuables. Ajoutez un nouveau taux lorsque le montant change. + + + No hourly rates have been recorded for this organization. + Aucun taux horaire n’a été enregistré pour cette organisation. + + + No hourly rates yet + Aucun taux horaire pour le moment + + + Rate history could not be loaded + L’historique des taux n’a pas pu être chargé + + + Loading rate history… + Chargement de l’historique des taux… + + + Reload rate history + Recharger l’historique des taux + + + Hourly rate history + Historique des taux horaires + + + Try loading this information again. + Réessayez de charger ces informations. + + + Reserve equipment could not be created because the organization quota was reached. + Les équipements en réserve n’ont pas pu être créés car le quota de l’organisation est atteint. + + + The physical return is retained. Inventory needs reconciliation before its movement can be confirmed. + Le retour physique est conservé. Un rapprochement du stock est nécessaire pour confirmer son mouvement. + + + The equipment type is currently unavailable. The physical receipt is retained. + Le type d’équipement est actuellement indisponible. La réception physique est conservée. + + + Equipment types could not be loaded + Les types d’équipements n’ont pas pu être chargés + + + Operation could not be confirmed + L’opération n’a pas pu être confirmée + + + Confirm operation + Confirmer l’opération + + + The supplier remains in existing purchases and history. New orders require an active supplier. + Le fournisseur reste associé aux achats existants et à leur historique. Les nouvelles commandes nécessitent un fournisseur actif. + + + Archive this supplier? + Archiver ce fournisseur ? + + + Only quantities still awaiting delivery are cancelled. Previous receipts and returns remain retained. + Seules les quantités encore attendues sont annulées. Les réceptions et retours précédents sont conservés. + + + Cancel undelivered quantities? + Annuler les quantités non livrées ? + + + The draft becomes an order. Physical deliveries will be recorded separately. + Le brouillon devient une commande. Les livraisons physiques seront enregistrées séparément. + + + Place this purchase order? + Passer cette commande d’achat ? + + + The server tries to confirm the inventory reversal. The original physical quantity and reason stay unchanged if stock is still insufficient. + Le serveur tente de confirmer le mouvement de retour du stock. La quantité physique et le motif d’origine restent inchangés si le stock est encore insuffisant. + + + Reconcile this physical return? + Rapprocher ce retour physique ? + + + Confirm operation + Confirmer l’opération + + + The server creates a bounded set of individually identified reserve equipment. A quota or unavailable type keeps the physical receipt awaiting action. + Le serveur crée un nombre limité d’équipements en réserve, chacun avec son identité. Un quota atteint ou un type indisponible conserve la réception physique en attente d’action. + + + Create reserve equipment for this receipt? + Créer les équipements en réserve pour cette réception ? + + + Organization currency. Leave empty when unknown. No commercial tax or billing is calculated here. + Devise de l’organisation. Laissez vide si le coût est inconnu. Aucune taxe commerciale ni facturation n’est calculée ici. + + + Enter a nonnegative amount with at most six decimals. + Saisissez un montant positif ou nul avec au plus six décimales. + + + Internal unit cost + Coût unitaire interne + + + Internal cost incomplete — unit cost unknown. + Coût interne incomplet : coût unitaire inconnu. + + + Internal unit cost: + Coût unitaire interne : + + + Prepare purchases, record physical deliveries and follow reserve equipment or stock without losing the source history. + Préparez les achats, enregistrez les livraisons physiques et suivez les équipements en réserve ou le stock en conservant leur historique d’origine. + + + Keep the source record and the actual physical quantities together. + Conservez ensemble le dossier d’origine et les quantités physiques réelles. + + + Purchase draft + Brouillon d’achat + + + Record a physical delivery + Enregistrer une livraison physique + + + Record a physical return + Enregistrer un retour physique + + + Supplier information + Informations du fournisseur + + + Procurement + Achats + + + Equipment to individualize + Matériel à individualiser + + + Stock article + Article de stock + + + Stock article label unavailable + Libellé de l’article de stock indisponible + + + Ordered: + Commandé : + + + Record a delivery + Enregistrer une livraison + + + Physically received: + Reçu physiquement : + + + Awaiting delivery: + En attente de livraison : + + + Physically returned: + Retourné physiquement : + + + unit unavailable + unité indisponible + + + units + unités + + + Add a line + Ajouter une ligne + + + Stock article + Article de stock + + + Brand + Marque + + + Choose a catalogue type + Choisir un type du catalogue + + + Received materials await explicit creation as reserve equipment. They are not quantitative stock. + Les matériels reçus attendent leur création explicite comme équipements en réserve. Ils ne constituent pas un stock quantitatif. + + + Equipment name + Nom de l’équipement + + + Purchase draft + Brouillon d’achat + + + Item kind + Nature de l’article + + + Order line + Ligne de commande + + + Add between 1 and 100 lines. + Ajoutez entre 1 et 100 lignes. + + + Loading suppliers… + Chargement des fournisseurs… + + + Model + Modèle + + + Order name + Nom de la commande + + + Enter the order name. + Saisissez le nom de la commande. + + + Next suppliers + Fournisseurs suivants + + + No active matching supplier loaded. + Aucun fournisseur actif correspondant n’est chargé. + + + Choose a stock article. + Choisissez un article de stock. + + + Previous suppliers + Fournisseurs précédents + + + Remove line + Supprimer la ligne + + + Save purchase draft + Enregistrer le brouillon d’achat + + + Purchase draft could not be saved + Le brouillon d’achat n’a pas pu être enregistré + + + Internal supplier + Fournisseur interne + + + Choose an internal supplier. + Choisissez un fournisseur interne. + + + Equipment type + Type d’équipement + + + Choose an equipment type. + Choisissez un type d’équipement. + + + All orders + Toutes les commandes + + + Cancel undelivered quantities + Annuler les quantités non livrées + + + Close order + Fermer la commande + + + New purchase draft + Nouveau brouillon d’achat + + + Created: + Créé le : + + + Source order could not be loaded + La commande d’origine n’a pas pu être chargée + + + Edit purchase draft + Modifier le brouillon d’achat + + + No purchase orders match this view + Aucune commande d’achat ne correspond à cette vue + + + Choose another status or prepare an internal purchase. + Choisissez un autre statut ou préparez un achat interne. + + + Order status filter + Filtrer par statut de commande + + + Purchase orders could not be loaded + Les commandes d’achat n’ont pas pu être chargées + + + Place purchase order + Passer la commande d’achat + + + View receipts + Voir les réceptions + + + Use a decimal point and at most six decimal places. Quantities and amounts remain exact. + Utilisez un point décimal et au plus six décimales. Les quantités et montants restent exacts. + + + Enter a positive quantity up to 100000 with at most six decimals. Equipment needs whole units. + Saisissez une quantité positive jusqu’à 100000, avec au plus six décimales. Les équipements nécessitent des unités entières. + + + Quantity + Quantité + + + Choose the actual UTC offset + Choisir le décalage UTC réel + + + Use the local date and time of your device. The recorded instant retains its timezone. + Utilisez la date et l’heure locales de votre appareil. L’instant enregistré conserve son fuseau horaire. + + + Materials remain in this receipt until you explicitly create their reserve equipment identities. No quantitative stock is added. + Les matériels restent dans cette réception jusqu’à la création explicite de leurs identités d’équipements en réserve. Aucun stock quantitatif n’est ajouté. + + + Receipt could not be confirmed + La réception n’a pas pu être confirmée + + + UTC offset for this repeated hour + Décalage UTC pour cette heure répétée + + + The same local time occurred twice because the clocks changed. Select the offset shown on the physical receipt record. + Cette heure locale s’est produite deux fois lors du changement d’heure. Choisissez le décalage indiqué sur le justificatif de réception physique. + + + This local hour occurs twice. Choose the UTC offset of the actual receipt. + Cette heure locale se produit deux fois. Choisissez le décalage UTC de la réception réelle. + + + Enter a positive quantity within the remaining delivery. Equipment needs whole units, up to 100 per receipt. + Saisissez une quantité positive ne dépassant pas le reste à livrer. Les équipements nécessitent des unités entières, jusqu’à 100 par réception. + + + Record physical receipt + Enregistrer la réception physique + + + Still awaiting delivery: . This receipt can contain up to . + Reste à livrer : . Cette réception peut contenir jusqu’à . + + + Physical receipt date and time + Date et heure de réception physique + + + Choose a valid receipt date and time that is not in the future. + Choisissez une date et une heure de réception valides qui ne soient pas dans le futur. + + + Enter the physical receipt date and time. + Saisissez la date et l’heure de réception physique. + + + Receiving warehouse + Magasin de réception + + + Choose the receiving warehouse. + Choisissez le magasin de réception. + + + No physical receipts recorded + Aucune réception physique enregistrée + + + Record the actual delivery from its source line above. Placing an order does not receive its goods. + Enregistrez la livraison réelle depuis sa ligne d’origine ci-dessus. Passer une commande ne vaut pas réception des marchandises. + + + Create reserve equipment + Créer les équipements en réserve + + + Receipts could not be loaded + Les réceptions n’ont pas pu être chargées + + + Return movement awaiting reconciliation: + Mouvement de retour à rapprocher : + + + Received: + Reçu : + + + Open reserve equipment dossier + Ouvrir le dossier de l’équipement en réserve + + + Record a physical return + Enregistrer un retour physique + + + Physically returned: + Retourné physiquement : + + + View return history + Voir l’historique des retours + + + Select the source purchase order + Sélectionnez la commande d’achat d’origine + + + Open an order's receipts to keep its physical deliveries and returns together. + Ouvrez les réceptions d’une commande pour retrouver ensemble ses livraisons et retours physiques. + + + Physical receipt history + Historique des réceptions physiques + + + Return could not be confirmed + Le retour n’a pas pu être confirmé + + + The return remains linked to its original receipt. If stock needs reconciliation, the physical declaration is retained until the movement can be confirmed. + Le retour reste lié à sa réception d’origine. Si le stock nécessite un rapprochement, la déclaration physique est conservée jusqu’à la confirmation du mouvement. + + + Received units not already returned: . + Unités reçues non encore retournées : . + + + Enter a positive quantity within the received units not already returned. + Saisissez une quantité positive ne dépassant pas les unités reçues non encore retournées. + + + Physical return reason + Motif du retour physique + + + Explain why the goods were returned. + Expliquez pourquoi les marchandises ont été retournées. + + + Record physical return + Enregistrer le retour physique + + + No physical returns recorded + Aucun retour physique enregistré + + + Each quantity and reason remains linked to its original receipt. Reconciliation confirms a stock movement and never rewrites the physical declaration. + Chaque quantité et chaque motif restent liés à la réception d’origine. Le rapprochement confirme un mouvement de stock sans réécrire la déclaration physique. + + + Return history could not be loaded + L’historique des retours n’a pas pu être chargé + + + Returned: + Retourné : + + + Reconcile inventory movement + Rapprocher le mouvement de stock + + + Physical returns and inventory reconciliation + Retours physiques et rapprochement du stock + + + Your typed draft is retained. Load the latest source before choosing to use its revision. + Votre brouillon saisi est conservé. Chargez la dernière version du dossier d’origine avant de choisir d’utiliser sa révision. + + + Load latest source + Charger la dernière version + + + Review the latest source revision + Vérifier la dernière révision du dossier + + + Use reviewed revision and keep my draft + Utiliser la révision vérifiée et conserver mon brouillon + + + Awaiting reserve equipment + En attente d’équipements en réserve + + + Awaiting inventory reconciliation + En attente de rapprochement du stock + + + Remaining quantities cancelled + Quantités restantes annulées + + + Draft + Brouillon + + + Reserve equipment created + Équipements en réserve créés + + + Ordered + Commandé + + + Partially received + Partiellement reçu + + + Received + Reçu + + + Inventory reversal confirmed + Mouvement de retour du stock confirmé + + + Returned + Retourné + + + Stock received + Stock reçu + + + Add contact + Ajouter un contact + + + Internal reference + Référence interne + + + Contact + Contact + + + Enter the contact name. + Saisissez le nom du contact. + + + Contacts + Contacts + + + Contacts are internal records. They do not receive access to FireGuard. + Les contacts sont des fiches internes. Ils ne reçoivent aucun accès à FireGuard. + + + Keep at most 50 contacts per supplier. + Conservez au plus 50 contacts par fournisseur. + + + Create supplier + Créer le fournisseur + + + Email + E-mail + + + Enter a valid email address. + Saisissez une adresse e-mail valide. + + + Supplier information + Informations du fournisseur + + + Use at most 160 characters. + Utilisez au plus 160 caractères. + + + Use at most 40 characters. + Utilisez au plus 40 caractères. + + + Use at most 80 characters. + Utilisez au plus 80 caractères. + + + Name + Nom + + + Enter the supplier name. + Saisissez le nom du fournisseur. + + + Telephone + Téléphone + + + Remove contact + Supprimer le contact + + + Role + Fonction + + + Save supplier + Enregistrer le fournisseur + + + Supplier could not be saved + Le fournisseur n’a pas pu être enregistré + + + Active suppliers + Fournisseurs actifs + + + All suppliers + Tous les fournisseurs + + + Archive supplier + Archiver le fournisseur + + + Archived suppliers + Fournisseurs archivés + + + Archived + Archivé + + + New internal supplier + Nouveau fournisseur interne + + + Edit supplier + Modifier le fournisseur + + + No suppliers match this view + Aucun fournisseur ne correspond à cette vue + + + Adjust your search or create an internal supplier. + Adaptez votre recherche ou créez un fournisseur interne. + + + Supplier archive filter + Filtrer les fournisseurs archivés + + + Suppliers could not be loaded + Les fournisseurs n’ont pas pu être chargés + + + Search suppliers + Rechercher des fournisseurs + + + Purchasing workspace + Espace achats + + + Orders + Commandes + + + Receipts + Réceptions + + + Suppliers + Fournisseurs + + + The response was lost. Retry the same declaration to recover its result before entering another physical operation. + La réponse a été perdue. Réessayez la même déclaration pour récupérer son résultat avant de saisir une autre opération physique. + + + Recover the original operation result + Récupérer le résultat de l’opération d’origine + + + Choose a task + Choisir une tâche + + + Maintenance exports + Exports de maintenance + + + Generate adjustment + Générer l’ajustement + + + Validated corrections produce a new linked archive. The original file and published dossier remain unchanged. + Les corrections validées créent une nouvelle archive liée. Le fichier d’origine et le dossier publié restent inchangés. + + + Confirm actual import + Confirmer l’import réalisé + + + Confirm only after your ERP has imported this exact archive. FireGuard does not send or import it automatically. + Confirmez seulement après l’import de cette archive exacte dans votre ERP. FireGuard ne l’envoie ni ne l’importe automatiquement. + + + Actual external import reference + Référence de l’import externe réalisé + + + Reason for adjustment + Motif de l’ajustement + + + Enter the reason or actual external import reference. + Saisissez le motif ou la référence de l’import externe réalisé. + + + Created + Créé le + + + No retained export yet + Aucun export conservé + + + Generate an export from ready published maintenance dossiers. + Générez un export à partir de dossiers de maintenance publiés et prêts. + + + Page of + Page sur + + + Private costs + Coûts privés + + + Rows + Lignes + + + External import state + État de l’import externe + + + System and version + Système et version + + + Retained archives + Archives conservées + + + Append adjustment + Ajouter un ajustement + + + Confirm external import + Confirmer l’import externe + + + Internal costs are incomplete. Unknown amounts are retained explicitly. + Les coûts internes sont incomplets. Les montants inconnus restent explicitement identifiés. + + + Published dossiers + Dossiers publiés + + + Download retained CSV + Télécharger le CSV conservé + + + Download retained JSON + Télécharger le JSON conservé + + + The file has been generated. No external import has been confirmed. + Le fichier a été généré. Aucun import externe n’a été confirmé. + + + External import confirmed + Import externe confirmé + + + Open original export + Ouvrir l’export d’origine + + + Open corrected export + Ouvrir l’export corrigé + + + Private archive including internal costs. + Archive privée incluant les coûts internes. + + + Retained prestation rows + Lignes de prestations conservées + + + Schema version + Version du format + + + Retained adjustment reason + Motif de l’ajustement conservé + + + Append an adjustment + Ajouter un ajustement + + + Use reviewed revision and keep my draft + Utiliser la version vérifiée et garder mon brouillon + + + Confirm an external import + Confirmer un import externe + + + The source changed or conflicts with this command. Your draft is retained. + La source a changé ou entre en conflit avec cette opération. Votre brouillon est conservé. + + + Generate a prestation export + Générer un export de prestations + + + Operations retain their identity across retries. Review a confirmed conflict before submitting a new command. + Les opérations gardent leur identité lors des nouvelles tentatives. Vérifiez un conflit confirmé avant d’envoyer une nouvelle opération. + + + Discard draft + Abandonner le brouillon + + + Discard the local draft and close this sheet? + Abandonner le brouillon local et fermer ce panneau ? + + + Keep editing + Continuer la saisie + + + Map an external resource reference + Associer une référence externe + + + Recover the same operation + Récupérer la même opération + + + Load latest source + Charger la source à jour + + + The response is uncertain. Recover this same operation before changing the draft. + La réponse est incertaine. Récupérez cette même opération avant de modifier le brouillon. + + + Unknown costs remain explicitly incomplete. This archive will require financial read permission. + Les coûts inconnus restent explicitement incomplets. Cette archive nécessitera le droit de lecture financière. + + + Published dossiers + Dossiers publiés + + + Generate retained export + Générer et conserver l’export + + + Include internal costs in this private export + Inclure les coûts internes dans cet export privé + + + Loading dossiers + Chargement des dossiers + + + No published dossier matches this search. + Aucun dossier publié ne correspond à cette recherche. + + + No validated work is retained in this dossier. It cannot be exported. + Ce dossier ne contient aucun travail validé conservé. Il ne peut pas être exporté. + + + Search published dossiers + Rechercher des dossiers publiés + + + dossier(s) selected. Selection is kept across pages. + dossier(s) sélectionné(s). La sélection est conservée entre les pages. + + + Select between 1 and 100 ready published dossiers. + Sélectionnez entre 1 et 100 dossiers publiés prêts. + + + Historical publication snapshot missing. This dossier cannot be exported. + L’instantané de publication historique est absent. Ce dossier ne peut pas être exporté. + + + Page + Page + + + External system code + Code du système externe + + + Identifies your ERP or target system. References are frozen when the export is generated. + Identifie votre ERP ou le système cible. Les références sont figées à la génération de l’export. + + + Use letters, numbers, dots, underscores or hyphens (up to 40 characters). + Utilisez lettres, chiffres, points, traits de soulignement ou traits d’union (40 caractères maximum). + + + Enter the external system code. + Saisissez le code du système externe. + + + Adjustment + Ajustement + + + Initial export + Export initial + + + Retain versioned CSV/JSON files and record actual ERP imports. Commercial invoicing remains in your ERP. + Conservez les fichiers CSV/JSON versionnés et tracez les imports réalisés dans l’ERP. La facturation commerciale reste dans votre ERP. + + + Generate export + Générer un export + + + Map a reference + Associer une référence + + + Prestation exports + Exports de prestations + + + Customer + Client + + + Current external references + Références externes actuelles + + + Equipment + Équipement + + + Mapping changes affect future exports. Retained archives keep their original external references. + Les changements de correspondance s’appliquent aux futurs exports. Les archives conservées gardent leurs références externes d’origine. + + + Loading resources + Chargement des ressources + + + Additional mappings are available through the scoped resource selector. + Les autres correspondances sont disponibles dans le sélecteur de ressources du périmètre. + + + Page + Page + + + A readable customer, site or equipment directory permission is required to choose a mapping target. + Le droit de lecture du répertoire des clients, sites ou équipements est nécessaire pour choisir une correspondance. + + + Reading current reference + Lecture de la référence actuelle + + + Save reviewed reference + Enregistrer la référence vérifiée + + + Search the resource directory + Rechercher dans le répertoire + + + Site + Site + + + Scoped resource + Ressource du périmètre + + + Select an authorized resource. + Sélectionnez une ressource autorisée. + + + Resource type + Type de ressource + + + External resource reference + Référence externe de la ressource + + + Enter the external resource reference. + Saisissez la référence externe de la ressource. + + + Import confirmed + Import confirmé + + + Export generated + Export généré + + + Use no more than 200 characters. + Utilisez 200 caractères au maximum. + + + Use no more than 1000 characters. + Utilisez 1000 caractères au maximum. + + + Clear search + Effacer la recherche + + + Find a work reference or name, open its private financial dossier, or narrow the economic report using its named scope. + Recherchez une référence ou un nom d’intervention, ouvrez son dossier financier privé ou limitez le rapport économique à son périmètre nommé. + + + No financial dossiers in this scope + Aucun dossier financier dans ce périmètre + + + Try another work reference, date window or named filter. + Essayez une autre référence, une autre période ou un autre filtre de périmètre. + + + Equipment report filters + Filtres du rapport par équipement + + + Financial dossiers could not be loaded + Impossible de charger les dossiers financiers + + + Historical identity is incomplete. Only available named filters can be selected. + L’identité historique est incomplète. Seuls les filtres disposant d’un libellé peuvent être sélectionnés. + + + Current work + Dossier courant + + + Reading financial dossiers + Chargement des dossiers financiers + + + Historical snapshot missing + Instantané historique manquant + + + Open financial dossier + Ouvrir le dossier financier + + + Published snapshot + Instantané publié + + + Published on + Publié le + + + Retry directory + Recharger l’annuaire + + + Limit to customer + Limiter au client + + + Limit to equipment + Limiter à l’équipement + + + Limit to site + Limiter au site + + + Search dossiers + Rechercher les dossiers + + + Work reference or name + Référence ou nom de l’intervention + + + Use at most 160 characters for the work reference or name. + Utilisez au maximum 160 caractères pour la référence ou le nom de l’intervention. + + + Financial dossiers + Dossiers financiers + + + Unnamed equipment + Équipement sans libellé + + + Read economic report + Consulter le rapport économique + + + Clear all named filters + Effacer tous les filtres de périmètre + + + Clear customer filter + Effacer le filtre de client + + + Clear equipment filter + Effacer le filtre d’équipement + + + Clear site filter + Effacer le filtre de site + + + Customer + Client + + + Choose a valid calendar date. + Choisissez une date calendaire valide. + + + Equipment + Équipement + + + From + Du + + + Choose the first report date. + Choisissez la première date du rapport. + + + Group costs by + Regrouper les coûts par + + + Choose equipment, site or customer grouping. + Choisissez un regroupement par équipement, site ou client. + + + Choose up to 366 calendar days. The first and last dates are included. + Choisissez une période de 366 jours calendaires au maximum. Le premier et le dernier jour sont inclus. + + + Selected filters + Filtres sélectionnés + + + Site + Site + + + Report scope + Périmètre du rapport + + + Through + Au + + + Choose the last report date. + Choisissez la dernière date du rapport. + + + Unnamed equipment + Équipement sans libellé + + + Choose an ordered date window of at most 366 days, including both dates. + Choisissez une période chronologique de 366 jours au maximum, premier et dernier jours inclus. + + + Budget + Budget + + + Economic cost allocations + Affectations des coûts économiques + + + Identity recorded at closure + Identité enregistrée à la clôture + + + Current realized cost + Coût réalisé courant + + + Source dossiers + Dossiers sources + + + No costs allocated in this window + Aucun coût affecté sur cette période + + + Choose another date window or review the explicitly unallocated contributions. + Choisissez une autre période ou consultez les contributions explicitement non affectées. + + + Budget is shown separately from forecast cost. The variance compares current realized cost with the forecast. + Le budget est présenté séparément du coût prévu. L’écart compare le coût réalisé courant au coût prévu. + + + Cost at closure + Coût à la clôture + + + Allocation + Affectation + + + Allocation incomplete + Affectation incomplète + + + Identity is incomplete + Identité incomplète + + + Known subtotal + Sous-total connu + + + Current identity + Identité actuelle + + + Reading economic allocations + Chargement des affectations économiques + + + Current and recorded identity + Identité actuelle et enregistrée + + + Forecast cost + Coût prévu + + + Unallocated costs + Coûts non affectés + + + No allocation identity + Aucune identité d’affectation + + + Historical identity unavailable + Identité historique indisponible + + + Unvalued contributions + Contributions non valorisées + + + Realized minus forecast + Réalisé moins prévu + + + View source dossiers + Consulter les dossiers sources + + + Allocated destinations + Destinations des coûts affectés + + + Browse financial dossiers and choose a scope + Parcourir les dossiers financiers et choisir un périmètre + + + The inclusive UTC date window selects published dossiers by publication date and open work by planned start, or creation date when no start is set. Costs show those dossiers' full current history, including later corrections. + La période inclusive en UTC sélectionne les dossiers publiés par date de publication et les travaux ouverts par début prévu, ou par date de création si aucun début n'est défini. Les coûts couvrent tout l'historique courant de ces dossiers, y compris les corrections ultérieures. + + + Compare internal maintenance spending with preparation, by equipment, site or customer. Each contribution is allocated once and unknown valuations remain visible. + Comparez les dépenses internes de maintenance aux prévisions, par équipement, site ou client. Chaque contribution est affectée une seule fois et les valorisations inconnues restent visibles. + + + Search the paginated financial directory to select a named site, customer or equipment. These minimal contexts are available with financial read permission alone. + Recherchez dans l'annuaire financier paginé pour sélectionner un site, un client ou un équipement par son nom. Ces informations minimales sont accessibles avec le seul droit de lecture financière. + + + Source dossiers: + Dossiers sources : + + + No partial total is displayed. Check the dates and use a shorter period if the report is too large. Named target filters refine the selected dossiers; they do not increase the period's source limit. + Aucun total partiel n'est affiché. Vérifiez les dates et réduisez la période si le rapport est trop volumineux. Les filtres de cibles précisent les dossiers sélectionnés ; ils n'augmentent pas la limite de sources de la période. + + + Financial response does not match the requested scope. + La réponse financière ne correspond pas au périmètre demandé. + + + Could not prepare the financial report + Impossible de préparer le rapport financier + + + Published dossiers without an immutable closure snapshot: . Their current facts remain visible, while the missing closure values are explicit. + Dossiers publiés sans instantané immuable de clôture : . Leurs données courantes restent visibles et l'absence des valeurs de clôture est explicite. + + + Some historical closure snapshots are missing + Certains dossiers historiques n'ont pas de coût figé à la clôture + + + Unknown prices, rates or estimates are not zero. Each incomplete metric shows its known subtotal, and a variance is available only when both amounts are complete. + Les prix, tarifs ou prévisions inconnus ne valent pas zéro. Chaque indicateur incomplet affiche son sous-total connu ; un écart n'est disponible que lorsque les deux montants sont complets. + + + Some valuations are unknown + Certaines valorisations sont inconnues + + + Preparing the financial report + Préparation du rapport financier + + + Global budgets + Budgets globaux + + + Current realized cost + Coût réalisé courant + + + Recorded at closure + Figé à la clôture + + + Known: + Connu : + + + Planned cost + Coût prévu + + + Realized cost minus estimate + Coût réalisé moins prévision + + + Financial dossiers and settings + Dossiers financiers et paramètres + + + Open economic pilotage + Ouvrir le pilotage économique + + + Resource estimates provide the planned cost. When a dossier has no estimated resources, its global budget serves as the forecast. The budget remains a separate metric and is never added to resource estimates. + Les estimations de ressources déterminent le coût prévu. Lorsqu'un dossier n'a aucune ressource estimée, son budget global sert de prévision. Le budget reste un indicateur distinct et n'est jamais ajouté aux estimations de ressources. + + + Orders: · Receipts: · Receipts awaiting individual equipment: + Commandes : · Réceptions : · Réceptions en attente d'individualisation : + + + Committed purchases + Achats engagés + + + Still to receive + Reste à recevoir + + + Gross deliveries + Livraisons brutes + + + Declared supplier returns + Retours fournisseurs déclarés + + + This organization-wide overview selects orders created in the date window, independently of equipment, site or customer filters. Deliveries and supplier returns cover the selected orders' entire histories. These purchase amounts are separate from consumed-piece costs. + Cette vue de l'organisation sélectionne les commandes créées dans la période, indépendamment des filtres équipement, site ou client. Les livraisons et retours fournisseurs couvrent l'historique complet des commandes sélectionnées. Ces montants d'achat restent distincts du coût des pièces consommées. + + + Organization procurement overview + Approvisionnements de l'organisation + + + Original source dossiers equal the selected allocations plus other destinations excluded by the equipment scope. General unallocated facts stay visible. + Les montants des dossiers sources correspondent aux affectations sélectionnées et aux autres destinations exclues par le périmètre équipement. Les données générales non affectées restent visibles. + + + Other excluded allocations, current cost + Autres affectations exclues, coût courant + + + Source dossiers, current cost + Dossiers sources, coût courant + + + Allocation reconciliation + Rapprochement des affectations + + + Retry report + Réessayer le rapport + + + These are the exact dossiers contributing to the selected allocation. Open their private cost dossier to compare current facts with the immutable closure. + Ces dossiers sont ceux qui contribuent à l'affectation sélectionnée. Ouvrez leur dossier financier privé pour comparer les données courantes à l'instantané immuable de clôture. + + + Open financial dossier + Ouvrir le dossier financier + + + Source financial dossiers + Dossiers financiers sources + + + These totals cover all filtered destinations and unallocated contributions, including results on other pages. Budget and resource estimates remain separate. + Ces totaux couvrent toutes les destinations du périmètre et les contributions non affectées, y compris les résultats des autres pages. Budgets et prévisions de ressources restent distincts. + + + Filtered maintenance totals + Totaux de maintenance du périmètre + + + Whole-dossier budgets, general expenses and unresolved identities remain here. They are counted once in the filtered totals and are never copied onto every equipment. + Les budgets globaux des dossiers, les dépenses générales et les identités non résolues restent ici. Ils sont comptés une seule fois dans les totaux du périmètre et ne sont jamais recopiés sur chaque équipement. + + + Unallocated contributions + Contributions non affectées + + + Economic pilotage + Pilotage économique + + + The calendar is locked while an occurrence is open. You can still change the operation name. + Le calendrier est figé tant qu’une occurrence est ouverte. Vous pouvez toujours modifier le nom de l’opération. + + + Search + Rechercher + + + Remove + Supprimer + + + Customer status + Statut du client + + + Archived customers can receive references for historical exports. + Les clients archivés peuvent recevoir des références pour les exports historiques. + + The previous declaration has no confirmed result. Retry it unchanged before creating another.La déclaration précédente n’a pas de résultat confirmé. Relancez-la à l’identique avant d’en créer une autre. + Could not recover previous declarationsImpossible de récupérer les déclarations précédentes + New declarations stay unavailable until recovery succeeds.Les nouvelles déclarations restent indisponibles jusqu’à la récupération. + Retry recoveryRéessayer la récupération + Wait for the current declaration to finish before leaving this page.Attendez la fin de la déclaration en cours avant de quitter cette page. diff --git a/src/locale/messages.xlf b/src/locale/messages.xlf index 1de2b0e2e..e6dc4919e 100644 --- a/src/locale/messages.xlf +++ b/src/locale/messages.xlf @@ -707,7 +707,7 @@ src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-schedules-page/maintenance-schedules-page.component.html - 193,195 + 217,219 src/app/features/organization/ui/pages/organization-members-page/organization-members-page.component.html @@ -1169,6 +1169,10 @@ src/app/features/organization/features/collaboration/ui/sheets/channel-participants-sheet/channel-participants-sheet.component.html 90,91 + + src/app/features/organization/features/customers/ui/forms/customer-form/customer-form.component.html + 178,180 + src/app/features/organization/features/equipments/ui/dialogs/equipment-assign-facility-dialog/equipment-assign-facility-dialog.component.html 52,54 @@ -1181,9 +1185,25 @@ src/app/features/organization/features/equipments/ui/dialogs/equipment-labels-dialog/equipment-labels-dialog.component.html 91,93 + + src/app/features/organization/features/equipments/ui/dialogs/equipment-replacement-confirm-dialog/equipment-replacement-confirm-dialog.component.html + 28,30 + + + src/app/features/organization/features/equipments/ui/forms/equipment-characteristics-form/equipment-characteristics-form.component.html + 128,130 + src/app/features/organization/features/equipments/ui/forms/equipment-create-form/equipment-create-form.component.html - 152,154 + 191,193 + + + src/app/features/organization/features/equipments/ui/forms/equipment-type-form/equipment-type-form.component.html + 88,90 + + + src/app/features/organization/features/equipments/ui/sheets/equipment-replacement-sheet/equipment-replacement-sheet.component.html + 164,165 src/app/features/organization/features/facilities/ui/components/facility-model-manager/facility-model-manager.component.html @@ -1223,7 +1243,7 @@ src/app/features/organization/features/facilities/ui/forms/facility-create-form/facility-create-form.component.html - 289,291 + 305,307 src/app/features/organization/features/inspections/ui/forms/inspection-create-form/inspection-create-form.component.html @@ -1281,6 +1301,10 @@ src/app/features/organization/features/interventions/ui/forms/intervention-create-form/intervention-create-form.component.html 243,245 + + src/app/features/organization/features/interventions/ui/forms/intervention-execution-result-form/intervention-execution-result-form.component.html + 164,166 + src/app/features/organization/features/interventions/ui/forms/intervention-recurrence-form/intervention-recurrence-form.component.html 348,350 @@ -1291,11 +1315,11 @@ src/app/features/organization/features/interventions/ui/forms/intervention-work-item-form/intervention-work-item-form.component.html - 309,310 + 320,321 src/app/features/organization/features/interventions/ui/forms/intervention-work-item-form/intervention-work-item-form.component.html - 378,380 + 389,391 src/app/features/organization/features/interventions/ui/sheets/intervention-create-sheet/intervention-create-sheet.component.html @@ -1303,7 +1327,27 @@ src/app/features/organization/features/interventions/ui/sheets/intervention-operations-sheet/intervention-operations-sheet.component.html - 162,164 + 182,184 + + + src/app/features/organization/features/inventory/ui/forms/inventory-movement-form/inventory-movement-form.component.html + 92,94 + + + src/app/features/organization/features/inventory/ui/forms/inventory-reference-form/inventory-reference-form.component.html + 90,92 + + + src/app/features/organization/features/maintenance-exports/ui/forms/maintenance-export-action-form/maintenance-export-action-form.component.html + 73,75 + + + src/app/features/organization/features/maintenance-exports/ui/forms/maintenance-export-create-form/maintenance-export-create-form.component.html + 172,174 + + + src/app/features/organization/features/maintenance-exports/ui/forms/maintenance-export-reference-form/maintenance-export-reference-form.component.html + 234,236 src/app/features/organization/features/maintenance-schedules/ui/dialogs/maintenance-override-dialog/maintenance-override-dialog.component.html @@ -1313,6 +1357,42 @@ src/app/features/organization/features/maintenance-schedules/ui/forms/maintenance-campaign-form/maintenance-campaign-form.component.html 113,115 + + src/app/features/organization/features/maintenance-schedules/ui/forms/maintenance-plan-form/maintenance-plan-form.component.html + 223,225 + + + src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-plans-page/maintenance-plans-page.component.html + 298,299 + + + src/app/features/organization/features/procurement/ui/forms/procurement-receipt-form/procurement-receipt-form.component.html + 126,127 + + + src/app/features/organization/features/procurement/ui/forms/procurement-return-form/procurement-return-form.component.html + 64,65 + + + src/app/features/organization/features/procurement/ui/forms/purchase-order-form/purchase-order-form.component.html + 301,302 + + + src/app/features/organization/features/procurement/ui/forms/supplier-form/supplier-form.component.html + 190,192 + + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.html + 1045,1047 + + + src/app/features/organization/features/service-requests/ui/forms/service-request-action-form/service-request-action-form.component.html + 136,138 + + + src/app/features/organization/features/service-requests/ui/forms/service-request-form/service-request-form.component.html + 134,136 + src/app/features/organization/features/webhooks/ui/forms/webhook-subscription-form/webhook-subscription-form.component.html 89,91 @@ -2466,10 +2546,42 @@ src/app/features/organization/features/collaboration/ui/pages/failed-messages-page/failed-messages-page.component.html 70,71 + + src/app/features/organization/features/customers/ui/components/customer-label/customer-label.component.html + 19,22 + + + src/app/features/organization/features/customers/ui/components/customer-picker/customer-picker.component.html + 74,77 + + + src/app/features/organization/features/customers/ui/pages/customers-page/customers-page.component.html + 82,84 + src/app/features/organization/features/equipments/ui/dialogs/equipment-labels-dialog/equipment-labels-dialog.component.html 75,76 + + src/app/features/organization/features/equipments/ui/pages/equipment-detail-page/equipment-detail-page.component.html + 91,92 + + + src/app/features/organization/features/equipments/ui/pages/equipment-detail-page/equipment-detail-page.component.html + 308,309 + + + src/app/features/organization/features/equipments/ui/pages/equipment-types-page/equipment-types-page.component.html + 62,64 + + + src/app/features/organization/features/equipments/ui/sheets/equipment-replacement-sheet/equipment-replacement-sheet.component.html + 35,37 + + + src/app/features/organization/features/equipments/ui/sheets/equipment-replacement-sheet/equipment-replacement-sheet.component.html + 151,152 + src/app/features/organization/features/facilities/ui/components/facility-model-manager/facility-model-manager.component.html 29,31 @@ -2530,6 +2642,138 @@ src/app/features/organization/features/interventions/ui/sheets/intervention-operations-sheet/intervention-operations-sheet.component.html 30,31 + + src/app/features/organization/features/inventory/ui/components/inventory-consumption-panel/inventory-consumption-panel.component.html + 39,40 + + + src/app/features/organization/features/inventory/ui/components/inventory-consumption-panel/inventory-consumption-panel.component.html + 83,84 + + + src/app/features/organization/features/inventory/ui/components/inventory-part-picker/inventory-part-picker.component.html + 85,87 + + + src/app/features/organization/features/inventory/ui/components/inventory-part-picker/inventory-part-picker.component.html + 103,105 + + + src/app/features/organization/features/inventory/ui/components/inventory-warehouse-picker/inventory-warehouse-picker.component.html + 85,87 + + + src/app/features/organization/features/inventory/ui/components/inventory-warehouse-picker/inventory-warehouse-picker.component.html + 103,105 + + + src/app/features/organization/features/inventory/ui/pages/inventory-page/inventory-page.component.html + 78,80 + + + src/app/features/organization/features/inventory/ui/pages/inventory-page/inventory-page.component.html + 261,262 + + + src/app/features/organization/features/maintenance-exports/ui/pages/maintenance-exports-page/maintenance-exports-page.component.html + 49,51 + + + src/app/features/organization/features/maintenance-schedules/ui/forms/maintenance-plan-form/maintenance-plan-form.component.html + 58,59 + + + src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-plans-page/maintenance-plans-page.component.html + 36,38 + + + src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-plans-page/maintenance-plans-page.component.html + 128,129 + + + src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-plans-page/maintenance-plans-page.component.html + 229,230 + + + src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-schedules-page/maintenance-schedules-page.component.html + 22,24 + + + src/app/features/organization/features/procurement/ui/forms/purchase-order-form/purchase-order-form.component.html + 66,67 + + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.html + 41,42 + + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.html + 58,59 + + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.html + 123,124 + + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.html + 272,273 + + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.html + 365,366 + + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.html + 467,468 + + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.html + 564,565 + + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.html + 733,734 + + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.html + 877,878 + + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.html + 939,940 + + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.html + 1018,1020 + + + src/app/features/organization/features/service-requests/ui/components/service-request-equipment-picker/service-request-equipment-picker.component.html + 80,83 + + + src/app/features/organization/features/service-requests/ui/pages/service-request-detail-page/service-request-detail-page.component.html + 27,29 + + + src/app/features/organization/features/service-requests/ui/pages/service-request-detail-page/service-request-detail-page.component.html + 65,66 + + + src/app/features/organization/features/service-requests/ui/pages/service-request-detail-page/service-request-detail-page.component.html + 272,274 + + + src/app/features/organization/features/service-requests/ui/pages/service-requests-page/service-requests-page.component.html + 37,39 + + + src/app/features/organization/features/service-requests/ui/pages/service-requests-page/service-requests-page.component.html + 110,111 + + + src/app/features/organization/features/service-requests/ui/sheets/service-request-editor-sheet/service-request-editor-sheet.component.html + 119,120 + src/app/features/organization/features/webhooks/ui/pages/webhooks-page/webhooks-page.component.html 23,25 @@ -2542,6 +2786,10 @@ src/app/features/organization/features/webhooks/ui/pages/webhooks-page/webhooks-page.component.html 472,473 + + src/app/features/organization/ui/components/organization-park-queues/organization-park-queues.component.html + 119,120 + src/app/features/organization/ui/pages/organization-select-page/organization-select-page.component.html 49,50 @@ -4073,7 +4321,7 @@ src/app/features/onboarding/ui/pages/onboarding-wizard-page/onboarding-wizard-page.component.ts - 258 + 291 @@ -4088,11 +4336,11 @@ src/app/features/onboarding/ui/pages/onboarding-wizard-page/onboarding-wizard-page.component.ts - 261 + 294 src/app/features/onboarding/ui/pages/onboarding-wizard-page/onboarding-wizard-page.component.ts - 1311 + 1458 @@ -4103,7 +4351,7 @@ src/app/features/onboarding/ui/pages/onboarding-wizard-page/onboarding-wizard-page.component.ts - 264 + 297 @@ -4219,6 +4467,10 @@ src/app/features/onboarding/ui/pages/onboarding-wizard-page/onboarding-wizard-page.component.html 182,183 + + src/app/features/onboarding/ui/pages/onboarding-wizard-page/onboarding-wizard-page.component.html + 221,223 + of completed @@ -4227,181 +4479,216 @@ 178 + + Equipment families + + src/app/features/onboarding/ui/forms/onboarding-equipment-form/onboarding-equipment-form.component.html + 15,16 + + + + Fire equipment + + src/app/features/onboarding/ui/forms/onboarding-equipment-form/onboarding-equipment-form.component.html + 24,27 + + + + All equipment + + src/app/features/onboarding/ui/forms/onboarding-equipment-form/onboarding-equipment-form.component.html + 32,34 + + + + No active fire equipment types. Choose All equipment to view the other available types. + + src/app/features/onboarding/ui/forms/onboarding-equipment-form/onboarding-equipment-form.component.html + 37,41 + + Type src/app/features/onboarding/ui/forms/onboarding-equipment-form/onboarding-equipment-form.component.html - 16,17 + 48,49 src/app/features/onboarding/ui/forms/onboarding-equipment-form/onboarding-equipment-form.component.html - 50,51 + 82,83 Select an equipment type src/app/features/onboarding/ui/forms/onboarding-equipment-form/onboarding-equipment-form.component.html - 43,46 + 75,78 src/app/features/onboarding/ui/forms/onboarding-equipment-form/onboarding-equipment-form.component.html - 52,54 + 84,86 src/app/features/onboarding/ui/forms/onboarding-equipment-form/onboarding-equipment-form.component.html - 120,121 + 152,153 Search equipment types src/app/features/onboarding/ui/forms/onboarding-equipment-form/onboarding-equipment-form.component.html - 61,63 + 93,95 src/app/features/onboarding/ui/forms/onboarding-equipment-form/onboarding-equipment-form.component.html - 69,70 + 101,102 No matching type. src/app/features/onboarding/ui/forms/onboarding-equipment-form/onboarding-equipment-form.component.html - 81,83 + 113,115 Current choice src/app/features/onboarding/ui/forms/onboarding-equipment-form/onboarding-equipment-form.component.html - 103,105 + 135,137 src/app/features/onboarding/ui/forms/onboarding-equipment-form/onboarding-equipment-form.component.html - 264,266 + 296,298 Facility src/app/features/onboarding/ui/forms/onboarding-equipment-form/onboarding-equipment-form.component.html - 154,157 + 186,189 src/app/features/onboarding/ui/forms/onboarding-equipment-form/onboarding-equipment-form.component.html - 173,176 + 205,208 src/app/features/onboarding/ui/forms/onboarding-equipment-form/onboarding-equipment-form.component.html - 209,210 + 241,242 Select a facility src/app/features/onboarding/ui/forms/onboarding-equipment-form/onboarding-equipment-form.component.html - 202,205 + 234,237 src/app/features/onboarding/ui/forms/onboarding-equipment-form/onboarding-equipment-form.component.html - 211,213 + 243,245 src/app/features/onboarding/ui/forms/onboarding-equipment-form/onboarding-equipment-form.component.html - 279,280 + 311,312 Search facilities src/app/features/onboarding/ui/forms/onboarding-equipment-form/onboarding-equipment-form.component.html - 220,222 + 252,254 src/app/features/onboarding/ui/forms/onboarding-equipment-form/onboarding-equipment-form.component.html - 228,229 + 260,261 No matching facility. src/app/features/onboarding/ui/forms/onboarding-equipment-form/onboarding-equipment-form.component.html - 240,242 + 272,274 src/app/features/onboarding/ui/forms/onboarding-equipment-form/onboarding-equipment-form.component.html - 285,288 + 317,320 Brand src/app/features/onboarding/ui/forms/onboarding-equipment-form/onboarding-equipment-form.component.html - 318,321 + 350,353 Kidde src/app/features/onboarding/ui/forms/onboarding-equipment-form/onboarding-equipment-form.component.html - 328 + 360 Model src/app/features/onboarding/ui/forms/onboarding-equipment-form/onboarding-equipment-form.component.html - 339,342 + 371,374 Pro 210 src/app/features/onboarding/ui/forms/onboarding-equipment-form/onboarding-equipment-form.component.html - 349 + 381 Serial number src/app/features/onboarding/ui/forms/onboarding-equipment-form/onboarding-equipment-form.component.html - 361,365 + 393,397 SN-2024-001 src/app/features/onboarding/ui/forms/onboarding-equipment-form/onboarding-equipment-form.component.html - 370,371 + 402,403 Select the facility for this equipment. src/app/features/onboarding/ui/forms/onboarding-equipment-form/onboarding-equipment-form.component.ts - 293 + 307 Equipment type is required. src/app/features/onboarding/ui/forms/onboarding-equipment-form/onboarding-equipment-form.component.ts - 296 + 310 + + + + This type is no longer available for new equipment. Choose an active type. + + src/app/features/onboarding/ui/forms/onboarding-equipment-form/onboarding-equipment-form.component.ts + 317 Register equipment src/app/features/onboarding/ui/forms/onboarding-equipment-form/onboarding-equipment-form.component.ts - 400 + 455 Registering… src/app/features/onboarding/ui/forms/onboarding-equipment-form/onboarding-equipment-form.component.ts - 414 + 469 @@ -4756,25 +5043,53 @@ 18,19 + + Organization profile + + src/app/features/onboarding/ui/forms/onboarding-organization-form/onboarding-organization-form.component.html + 31,33 + + + + Site operator + + src/app/features/onboarding/ui/forms/onboarding-organization-form/onboarding-organization-form.component.html + 49,51 + + + + Service provider + + src/app/features/onboarding/ui/forms/onboarding-organization-form/onboarding-organization-form.component.html + 58,60 + + + + Choose how you manage your fire-safety equipment. Access remains determined by your organization's permissions. + + src/app/features/onboarding/ui/forms/onboarding-organization-form/onboarding-organization-form.component.html + 62,65 + + Create organization src/app/features/onboarding/ui/forms/onboarding-organization-form/onboarding-organization-form.component.ts - 111 + 162 Creating… src/app/features/onboarding/ui/forms/onboarding-organization-form/onboarding-organization-form.component.ts - 114 + 178 Enter your organization's name. src/app/features/onboarding/ui/forms/onboarding-organization-form/onboarding-organization-form.component.ts - 133 + 215 @@ -4874,7 +5189,7 @@ src/app/features/onboarding/ui/pages/onboarding-wizard-page/onboarding-wizard-page.component.html - 294,297 + 333,336 @@ -4913,15 +5228,19 @@ src/app/features/onboarding/ui/pages/onboarding-wizard-page/onboarding-wizard-page.component.ts - 1344 + 1491 + + + src/app/features/onboarding/ui/pages/onboarding-wizard-page/onboarding-wizard-page.component.ts + 1528 src/app/features/onboarding/ui/pages/onboarding-wizard-page/onboarding-wizard-page.component.ts - 1381 + 1573 src/app/features/onboarding/ui/pages/onboarding-wizard-page/onboarding-wizard-page.component.ts - 1418 + 1610 @@ -4944,61 +5263,79 @@ src/app/features/onboarding/ui/pages/onboarding-wizard-page/onboarding-wizard-page.component.html 169,171 + + src/app/features/onboarding/ui/pages/onboarding-wizard-page/onboarding-wizard-page.component.html + 208,210 + + + + No active equipment types + + src/app/features/onboarding/ui/pages/onboarding-wizard-page/onboarding-wizard-page.component.html + 192,194 + + + + An active type is needed to register equipment. Refresh the available choices after the organization's types have been updated. + + src/app/features/onboarding/ui/pages/onboarding-wizard-page/onboarding-wizard-page.component.html + 195,198 + Your activation is blocked. src/app/features/onboarding/ui/pages/onboarding-wizard-page/onboarding-wizard-page.component.html - 260,262 + 299,301 Go back a step src/app/features/onboarding/ui/pages/onboarding-wizard-page/onboarding-wizard-page.component.html - 276,278 + 315,317 Loading activation src/app/features/onboarding/ui/pages/onboarding-wizard-page/onboarding-wizard-page.component.html - 302,303 + 341,342 Join an existing organization src/app/features/onboarding/ui/pages/onboarding-wizard-page/onboarding-wizard-page.component.html - 323,326 + 362,365 Step of src/app/features/onboarding/ui/pages/onboarding-wizard-page/onboarding-wizard-page.component.ts - 582 + 685 Last step — your workspace opens right after. src/app/features/onboarding/ui/pages/onboarding-wizard-page/onboarding-wizard-page.component.ts - 816 + 919 Next: src/app/features/onboarding/ui/pages/onboarding-wizard-page/onboarding-wizard-page.component.ts - 821 + 924 Your organization is ready. src/app/features/onboarding/ui/pages/onboarding-wizard-page/onboarding-wizard-page.component.ts - 1035 + 1178 @@ -5231,35 +5568,35 @@ Awaiting approval src/app/features/onboarding/ui/pages/onboarding-workspace-page/onboarding-workspace-page.component.ts - 259 + 394 Approved src/app/features/onboarding/ui/pages/onboarding-workspace-page/onboarding-workspace-page.component.ts - 261 + 396 Declined src/app/features/onboarding/ui/pages/onboarding-workspace-page/onboarding-workspace-page.component.ts - 263 + 398 Cancelled src/app/features/onboarding/ui/pages/onboarding-workspace-page/onboarding-workspace-page.component.ts - 265 + 400 Expired src/app/features/onboarding/ui/pages/onboarding-workspace-page/onboarding-workspace-page.component.ts - 267 + 402 @@ -5305,7 +5642,7 @@ src/app/features/organization/navigation/organization-navigation.config.ts - 463 + 516 @@ -5615,10 +5952,6 @@ src/app/features/organization/features/approvals/ui/pages/approvals-page/approvals-page.component.ts 902 - - src/app/features/organization/features/equipments/ui/components/equipment-information-panel/equipment-information-panel.component.ts - 176 - src/app/features/organization/features/equipments/ui/dialogs/equipment-labels-dialog/equipment-labels-dialog.component.ts 417 @@ -5629,7 +5962,7 @@ src/app/features/organization/features/maintenance-schedules/ui/forms/maintenance-campaign-form/maintenance-campaign-form.component.ts - 369 + 371 src/app/features/organization/ui/forms/organization-approval-form/organization-approval-form.component.ts @@ -5767,7 +6100,7 @@ src/app/features/organization/features/interventions/ui/forms/intervention-work-item-form/intervention-work-item-form.component.ts - 686 + 834 src/app/features/organization/state/member-directory/member-directory.store.ts @@ -6822,7 +7155,7 @@ src/app/features/organization/navigation/organization-navigation.config.ts - 415 + 424 @@ -6912,6 +7245,14 @@ src/app/features/organization/features/collaboration/ui/pages/failed-messages-page/failed-messages-page.component.html 22,24 + + src/app/features/organization/features/interventions/ui/forms/intervention-execution-result-form/intervention-execution-result-form.component.html + 58,59 + + + src/app/features/organization/features/maintenance-exports/ui/pages/maintenance-exports-page/maintenance-exports-page.component.html + 225,227 + src/app/features/organization/features/webhooks/ui/pages/webhooks-page/webhooks-page.component.html 245,246 @@ -7010,7 +7351,7 @@ src/app/features/organization/navigation/organization-navigation.config.ts - 423 + 476 @@ -7026,18 +7367,38 @@ src/app/features/organization/features/calendar/ui/components/calendar-entry-list/calendar-entry-list.component.html 39,40 + + src/app/features/organization/features/customers/ui/pages/customers-page/customers-page.component.html + 144 + src/app/features/organization/features/equipments/ui/pages/equipment-detail-page/equipment-detail-page.component.html - 66,67 + 173,174 + + + src/app/features/organization/features/equipments/ui/pages/equipment-detail-page/equipment-detail-page.component.html + 408,409 + + + src/app/features/organization/features/equipments/ui/pages/equipment-types-page/equipment-types-page.component.html + 121,122 src/app/features/organization/features/interventions/ui/tables/intervention-recurrence-table/intervention-recurrence-table.component.html 237,239 + + src/app/features/organization/features/inventory/ui/dataviews/inventory-dataview/inventory-dataview.component.html + 67,68 + src/app/features/organization/features/maintenance-schedules/ui/tables/maintenance-schedule-table/maintenance-schedule-table.component.html 197,200 + + src/app/features/organization/features/service-requests/ui/pages/service-request-detail-page/service-request-detail-page.component.html + 91,92 + src/app/features/organization/features/webhooks/ui/pages/webhooks-page/webhooks-page.component.html 216,217 @@ -7317,7 +7678,7 @@ src/app/features/organization/features/interventions/ui/pages/intervention-detail-page/intervention-detail-page.component.html - 1419,1421 + 1617,1619 src/app/features/organization/features/interventions/ui/sheets/intervention-discussion-sheet/intervention-discussion-sheet.component.html @@ -7333,11 +7694,15 @@ src/app/features/organization/features/interventions/ui/sheets/intervention-operations-sheet/intervention-operations-sheet.component.html - 114,116 + 124,126 src/app/features/organization/features/interventions/ui/sheets/intervention-time-sheet/intervention-time-sheet.component.html - 234,235 + 368,369 + + + src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-reports-page/maintenance-reports-page.component.html + 353,354 src/app/features/organization/features/workload/ui/sheets/workload-day-sheet/workload-day-sheet.component.html @@ -7755,7 +8120,7 @@ src/app/features/organization/features/equipments/ui/pages/equipment-detail-page/equipment-detail-page.component.html - 272,274 + 558,560 src/app/features/organization/features/facilities/ui/pages/facility-detail-page/facility-detail-page.component.html @@ -7787,7 +8152,7 @@ src/app/features/organization/features/interventions/ui/pages/intervention-detail-page/intervention-detail-page.component.html - 728,730 + 885,887 src/app/features/organization/features/interventions/ui/sheets/intervention-time-sheet/intervention-time-sheet.component.html @@ -7795,7 +8160,15 @@ src/app/features/organization/features/interventions/ui/sheets/intervention-time-sheet/intervention-time-sheet.component.html - 105,106 + 67,68 + + + src/app/features/organization/features/interventions/ui/sheets/intervention-time-sheet/intervention-time-sheet.component.html + 128,129 + + + src/app/features/organization/features/interventions/ui/sheets/intervention-time-sheet/intervention-time-sheet.component.html + 258,259 src/app/features/organization/features/interventions/ui/tables/intervention-recurrence-table/intervention-recurrence-table.component.html @@ -7815,27 +8188,27 @@ src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 96,97 + 144,145 src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 292,293 + 340,341 src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 598,599 + 688,689 src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 832,833 + 933,934 src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 1061,1063 + 1170,1172 src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 1377,1379 + 1486,1488 src/app/shared/board/ui/components/board/board.component.html @@ -8064,7 +8437,7 @@ src/app/features/organization/navigation/organization-navigation.config.ts - 447 + 500 @@ -8777,6 +9150,14 @@ src/app/features/organization/features/collaboration/ui/components/assistant-panel/assistant-panel.component.html 82,84 + + src/app/features/organization/features/customers/ui/components/customer-picker/customer-picker.component.html + 43,45 + + + src/app/features/organization/features/equipments/ui/sheets/equipment-replacement-sheet/equipment-replacement-sheet.component.html + 128,129 + src/app/features/organization/features/facilities/ui/components/facility-option-picker/facility-option-picker.component.html 46,48 @@ -8805,6 +9186,34 @@ src/app/features/organization/features/inspections/ui/forms/inspection-create-form/inspection-create-form.component.html 325,327 + + src/app/features/organization/features/interventions/ui/sheets/intervention-time-sheet/intervention-time-sheet.component.html + 340,341 + + + src/app/features/organization/features/inventory/ui/components/inventory-part-picker/inventory-part-picker.component.html + 50,52 + + + src/app/features/organization/features/inventory/ui/components/inventory-warehouse-picker/inventory-warehouse-picker.component.html + 50,52 + + + src/app/features/organization/features/maintenance-exports/ui/forms/maintenance-export-create-form/maintenance-export-create-form.component.html + 109,111 + + + src/app/features/organization/features/maintenance-exports/ui/forms/maintenance-export-reference-form/maintenance-export-reference-form.component.html + 161,162 + + + src/app/features/organization/features/maintenance-exports/ui/pages/maintenance-exports-page/maintenance-exports-page.component.html + 300,301 + + + src/app/features/organization/features/service-requests/ui/components/service-request-equipment-picker/service-request-equipment-picker.component.html + 45,47 + Next @@ -8812,6 +9221,14 @@ src/app/features/organization/features/collaboration/ui/components/assistant-panel/assistant-panel.component.html 96,97 + + src/app/features/organization/features/customers/ui/components/customer-picker/customer-picker.component.html + 59,61 + + + src/app/features/organization/features/equipments/ui/sheets/equipment-replacement-sheet/equipment-replacement-sheet.component.html + 139,140 + src/app/features/organization/features/facilities/ui/components/facility-option-picker/facility-option-picker.component.html 60,63 @@ -8840,6 +9257,34 @@ src/app/features/organization/features/inspections/ui/forms/inspection-create-form/inspection-create-form.component.html 341,342 + + src/app/features/organization/features/interventions/ui/sheets/intervention-time-sheet/intervention-time-sheet.component.html + 352,353 + + + src/app/features/organization/features/inventory/ui/components/inventory-part-picker/inventory-part-picker.component.html + 67,69 + + + src/app/features/organization/features/inventory/ui/components/inventory-warehouse-picker/inventory-warehouse-picker.component.html + 67,69 + + + src/app/features/organization/features/maintenance-exports/ui/forms/maintenance-export-create-form/maintenance-export-create-form.component.html + 123,124 + + + src/app/features/organization/features/maintenance-exports/ui/forms/maintenance-export-reference-form/maintenance-export-reference-form.component.html + 173,174 + + + src/app/features/organization/features/maintenance-exports/ui/pages/maintenance-exports-page/maintenance-exports-page.component.html + 312,313 + + + src/app/features/organization/features/service-requests/ui/components/service-request-equipment-picker/service-request-equipment-picker.component.html + 62,65 + Loading the conversation… @@ -10424,21 +10869,453 @@ 289 + + Customers + + src/app/features/organization/features/customers/customers.routes.ts + 19 + + + src/app/features/organization/features/customers/customers.routes.ts + 21 + + + + No customer + + src/app/features/organization/features/customers/ui/components/customer-label/customer-label.component.html + 2,3 + + + src/app/features/organization/features/customers/ui/components/customer-picker/customer-picker.component.html + 17,18 + + + src/app/features/organization/features/customers/ui/components/customer-picker/customer-picker.component.ts + 166 + + + src/app/features/organization/features/facilities/ui/components/facility-information-panel/facility-information-panel.component.html + 65,67 + + + + (archived) + + src/app/features/organization/features/customers/ui/components/customer-label/customer-label.component.html + 6,8 + + + + Loading customer + + src/app/features/organization/features/customers/ui/components/customer-label/customer-label.component.html + 25,26 + + + + No matching customers + + src/app/features/organization/features/customers/ui/components/customer-picker/customer-picker.component.html + 28,31 + + + + Choose a customer + + src/app/features/organization/features/customers/ui/components/customer-picker/customer-picker.component.ts + 132 + + + + (archived) + + src/app/features/organization/features/customers/ui/components/customer-picker/customer-picker.component.ts + 149 + + + + Customer information + + src/app/features/organization/features/customers/ui/forms/customer-form/customer-form.component.html + 3,5 + + + + Name + + src/app/features/organization/features/customers/ui/forms/customer-form/customer-form.component.html + 6,8 + + + src/app/features/organization/features/customers/ui/forms/customer-form/customer-form.component.html + 73,75 + + + src/app/features/organization/features/customers/ui/pages/customers-page/customers-page.component.html + 100,101 + + + + Internal reference + + src/app/features/organization/features/customers/ui/forms/customer-form/customer-form.component.html + 21,23 + + + src/app/features/organization/features/customers/ui/pages/customers-page/customers-page.component.html + 101,102 + + + + Email + + src/app/features/organization/features/customers/ui/forms/customer-form/customer-form.component.html + 31,33 + + + src/app/features/organization/features/customers/ui/forms/customer-form/customer-form.component.html + 103,105 + + + + Telephone + + src/app/features/organization/features/customers/ui/forms/customer-form/customer-form.component.html + 46,48 + + + src/app/features/organization/features/customers/ui/forms/customer-form/customer-form.component.html + 119,121 + + + + Contacts + + src/app/features/organization/features/customers/ui/forms/customer-form/customer-form.component.html + 63,64 + + + src/app/features/organization/features/customers/ui/pages/customers-page/customers-page.component.html + 102,103 + + + + Contacts are internal records. They do not receive access to FireGuard. + + src/app/features/organization/features/customers/ui/forms/customer-form/customer-form.component.html + 65,67 + + + + Contact + + src/app/features/organization/features/customers/ui/forms/customer-form/customer-form.component.html + 69,70 + + + + Role + + src/app/features/organization/features/customers/ui/forms/customer-form/customer-form.component.html + 88,90 + + + + Remove contact + + src/app/features/organization/features/customers/ui/forms/customer-form/customer-form.component.html + 142,144 + + + + Add contact + + src/app/features/organization/features/customers/ui/forms/customer-form/customer-form.component.html + 159,162 + + + + Customer could not be saved + + src/app/features/organization/features/customers/ui/forms/customer-form/customer-form.component.html + 164,165 + + + + Keep at most 50 contacts per customer. + + src/app/features/organization/features/customers/ui/forms/customer-form/customer-form.component.ts + 324 + + + + Enter the customer name. + + src/app/features/organization/features/customers/ui/forms/customer-form/customer-form.component.ts + 328 + + + src/app/features/organization/features/customers/ui/forms/customer-form/customer-form.component.ts + 335 + + + + Use at most 160 characters. + + src/app/features/organization/features/customers/ui/forms/customer-form/customer-form.component.ts + 339 + + + src/app/features/organization/features/customers/ui/forms/customer-form/customer-form.component.ts + 366 + + + + Use at most 80 characters. + + src/app/features/organization/features/customers/ui/forms/customer-form/customer-form.component.ts + 342 + + + src/app/features/organization/features/customers/ui/forms/customer-form/customer-form.component.ts + 378 + + + + Enter a valid email address. + + src/app/features/organization/features/customers/ui/forms/customer-form/customer-form.component.ts + 345 + + + src/app/features/organization/features/customers/ui/forms/customer-form/customer-form.component.ts + 348 + + + src/app/features/organization/features/customers/ui/forms/customer-form/customer-form.component.ts + 369 + + + src/app/features/organization/features/customers/ui/forms/customer-form/customer-form.component.ts + 372 + + + + Use at most 40 characters. + + src/app/features/organization/features/customers/ui/forms/customer-form/customer-form.component.ts + 351 + + + src/app/features/organization/features/customers/ui/forms/customer-form/customer-form.component.ts + 375 + + + + Enter the contact name. + + src/app/features/organization/features/customers/ui/forms/customer-form/customer-form.component.ts + 355 + + + src/app/features/organization/features/customers/ui/forms/customer-form/customer-form.component.ts + 362 + + + + Save customer + + src/app/features/organization/features/customers/ui/forms/customer-form/customer-form.component.ts + 396 + + + + Create customer + + src/app/features/organization/features/customers/ui/forms/customer-form/customer-form.component.ts + 397 + + + + Customers + + src/app/features/organization/features/customers/ui/pages/customers-page/customers-page.component.html + 4,5 + + + + Internal customer records and contacts for your sites. + + src/app/features/organization/features/customers/ui/pages/customers-page/customers-page.component.html + 6,9 + + + + New customer + + src/app/features/organization/features/customers/ui/pages/customers-page/customers-page.component.html + 19,22 + + + src/app/features/organization/features/customers/ui/sheets/customer-editor-sheet/customer-editor-sheet.component.ts + 230 + + + + Customers are managed online + + src/app/features/organization/features/customers/ui/pages/customers-page/customers-page.component.html + 27,28 + + + + Reconnect to update customer records. Your draft is preserved. + + src/app/features/organization/features/customers/ui/pages/customers-page/customers-page.component.html + 29,33 + + + + Search customers + + src/app/features/organization/features/customers/ui/pages/customers-page/customers-page.component.html + 36,38 + + + + Customer status + + src/app/features/organization/features/customers/ui/pages/customers-page/customers-page.component.html + 50,51 + + + + Active + + src/app/features/organization/features/customers/ui/pages/customers-page/customers-page.component.html + 59,61 + + + src/app/features/organization/features/maintenance-exports/ui/forms/maintenance-export-reference-form/maintenance-export-reference-form.component.html + 72,74 + + + + Archived + + src/app/features/organization/features/customers/ui/pages/customers-page/customers-page.component.html + 66,68 + + + src/app/features/organization/features/customers/ui/pages/customers-page/customers-page.component.html + 112,114 + + + src/app/features/organization/features/maintenance-exports/ui/forms/maintenance-export-reference-form/maintenance-export-reference-form.component.html + 81,83 + + + + Customers could not be loaded + + src/app/features/organization/features/customers/ui/pages/customers-page/customers-page.component.html + 72,73 + + + + Customer action failed + + src/app/features/organization/features/customers/ui/pages/customers-page/customers-page.component.html + 88,89 + + + + Actions + + src/app/features/organization/features/customers/ui/pages/customers-page/customers-page.component.html + 103,105 + + + + Restore + + src/app/features/organization/features/customers/ui/pages/customers-page/customers-page.component.html + 155,156 + + + + Archive + + src/app/features/organization/features/customers/ui/pages/customers-page/customers-page.component.html + 157,159 + + + + No customers in this view + + src/app/features/organization/features/customers/ui/pages/customers-page/customers-page.component.html + 188,189 + + + + Change your search or create a customer. Sites can also be managed without a customer. + + src/app/features/organization/features/customers/ui/pages/customers-page/customers-page.component.html + 190,191 + + + + Keep the customer's identity and contacts together for its sites. + + src/app/features/organization/features/customers/ui/sheets/customer-editor-sheet/customer-editor-sheet.component.html + 14,16 + + + + Refresh the latest version and keep my draft + + src/app/features/organization/features/customers/ui/sheets/customer-editor-sheet/customer-editor-sheet.component.html + 27,30 + + + + Edit customer + + src/app/features/organization/features/customers/ui/sheets/customer-editor-sheet/customer-editor-sheet.component.ts + 229 + + Equipments src/app/features/organization/features/equipments/equipments.routes.ts - 75 + 77 src/app/features/organization/features/equipments/equipments.routes.ts - 85 + 87 src/app/features/organization/navigation/organization-mobile-navigation.config.ts 169 + + Equipment types + + src/app/features/organization/features/equipments/equipments.routes.ts + 104 + + + src/app/features/organization/features/equipments/equipments.routes.ts + 105 + + + src/app/features/organization/features/equipments/ui/pages/equipments-page/equipments-page.component.html + 250,254 + + Equipment @@ -10458,7 +11335,7 @@ src/app/features/organization/features/interventions/models/intervention-tag/intervention-tag.util.ts - 273 + 335 @@ -10473,7 +11350,7 @@ src/app/features/organization/features/interventions/models/intervention-tag/intervention-tag.util.ts - 278 + 340 @@ -10488,7 +11365,7 @@ src/app/features/organization/features/interventions/models/intervention-tag/intervention-tag.util.ts - 283 + 345 @@ -10503,7 +11380,7 @@ src/app/features/organization/features/interventions/models/intervention-tag/intervention-tag.util.ts - 288 + 350 @@ -10554,84 +11431,84 @@ Fire extinguisher src/app/features/organization/features/equipments/options/equipment-type-options.constants.ts - 30 + 27 Smoke detector src/app/features/organization/features/equipments/options/equipment-type-options.constants.ts - 35 + 32 Heat detector src/app/features/organization/features/equipments/options/equipment-type-options.constants.ts - 40 + 37 Sprinkler src/app/features/organization/features/equipments/options/equipment-type-options.constants.ts - 45 + 42 Fire alarm panel src/app/features/organization/features/equipments/options/equipment-type-options.constants.ts - 50 + 47 Hydrant src/app/features/organization/features/equipments/options/equipment-type-options.constants.ts - 55 + 52 Fire door src/app/features/organization/features/equipments/options/equipment-type-options.constants.ts - 60 + 57 Emergency lighting src/app/features/organization/features/equipments/options/equipment-type-options.constants.ts - 65 + 62 Access control src/app/features/organization/features/equipments/options/equipment-type-options.constants.ts - 70 + 67 Camera src/app/features/organization/features/equipments/options/equipment-type-options.constants.ts - 73 + 70 Gas detector src/app/features/organization/features/equipments/options/equipment-type-options.constants.ts - 76 + 73 Other src/app/features/organization/features/equipments/options/equipment-type-options.constants.ts - 79 + 76 @@ -10645,7 +11522,7 @@ Equipment deleted src/app/features/organization/features/equipments/state/equipment/equipment.store.ts - 868 + 941 @@ -10741,105 +11618,127 @@ 7,10 + + Equipment name + + src/app/features/organization/features/equipments/ui/components/equipment-information-panel/equipment-information-panel.component.html + 13,14 + + + src/app/features/organization/features/equipments/ui/forms/equipment-create-form/equipment-create-form.component.html + 43,46 + + + + Asset reference + + src/app/features/organization/features/equipments/ui/components/equipment-information-panel/equipment-information-panel.component.html + 38,39 + + + src/app/features/organization/features/equipments/ui/forms/equipment-create-form/equipment-create-form.component.html + 60,63 + + Type src/app/features/organization/features/equipments/ui/components/equipment-information-panel/equipment-information-panel.component.html - 13 + 64 src/app/features/organization/features/equipments/ui/components/equipment-information-panel/equipment-information-panel.component.html - 30,31 + 81,82 Subtype src/app/features/organization/features/equipments/ui/components/equipment-information-panel/equipment-information-panel.component.html - 51 + 102 Not specified src/app/features/organization/features/equipments/ui/components/equipment-information-panel/equipment-information-panel.component.html - 65,67 + 116,118 src/app/features/organization/features/equipments/ui/components/equipment-information-panel/equipment-information-panel.component.html - 99,101 + 149,151 src/app/features/organization/features/equipments/ui/components/equipment-information-panel/equipment-information-panel.component.html - 133,135 + 182,184 src/app/features/organization/features/equipments/ui/components/equipment-information-panel/equipment-information-panel.component.html - 167,169 + 215,217 src/app/features/organization/features/equipments/ui/components/equipment-information-panel/equipment-information-panel.component.html - 210,212 + 257,259 Brand src/app/features/organization/features/equipments/ui/components/equipment-information-panel/equipment-information-panel.component.html - 85 + 135 Model src/app/features/organization/features/equipments/ui/components/equipment-information-panel/equipment-information-panel.component.html - 119 + 168 Serial number src/app/features/organization/features/equipments/ui/components/equipment-information-panel/equipment-information-panel.component.html - 152,153 + 200,201 Placement & lifecycle src/app/features/organization/features/equipments/ui/components/equipment-information-panel/equipment-information-panel.component.html - 189,192 + 236,239 Location src/app/features/organization/features/equipments/ui/components/equipment-information-panel/equipment-information-panel.component.html - 195,196 + 242,243 Installed src/app/features/organization/features/equipments/ui/components/equipment-information-panel/equipment-information-panel.component.html - 231,233 + 277,279 Not recorded src/app/features/organization/features/equipments/ui/components/equipment-information-panel/equipment-information-panel.component.html - 239,242 + 285,288 src/app/features/organization/features/equipments/ui/components/equipment-information-panel/equipment-information-panel.component.html - 257,260 + 303,306 Commissioned src/app/features/organization/features/equipments/ui/components/equipment-information-panel/equipment-information-panel.component.html - 249,252 + 295,298 @@ -11199,6 +12098,222 @@ 102,104 + + Confirm the replacement + + src/app/features/organization/features/equipments/ui/dialogs/equipment-replacement-confirm-dialog/equipment-replacement-confirm-dialog.component.html + 11,13 + + + + will be retired permanently. Its dossier and history remain accessible through the successor. + + src/app/features/organization/features/equipments/ui/dialogs/equipment-replacement-confirm-dialog/equipment-replacement-confirm-dialog.component.html + 14,16 + + + + Confirm replacement + + src/app/features/organization/features/equipments/ui/dialogs/equipment-replacement-confirm-dialog/equipment-replacement-confirm-dialog.component.html + 39,41 + + + src/app/features/organization/features/equipments/ui/forms/equipment-create-form/equipment-create-form.component.html + 205,207 + + + src/app/features/organization/features/equipments/ui/sheets/equipment-replacement-sheet/equipment-replacement-sheet.component.html + 174,176 + + + + Declared criticality + + src/app/features/organization/features/equipments/ui/forms/equipment-characteristics-form/equipment-characteristics-form.component.html + 11,13 + + + src/app/features/organization/features/equipments/ui/pages/equipment-detail-page/equipment-detail-page.component.html + 424,426 + + + + Technical characteristics + + src/app/features/organization/features/equipments/ui/forms/equipment-characteristics-form/equipment-characteristics-form.component.html + 28,30 + + + src/app/features/organization/features/equipments/ui/pages/equipment-detail-page/equipment-detail-page.component.html + 397,399 + + + + Declared information about this equipment; it does not determine control obligations. + + src/app/features/organization/features/equipments/ui/forms/equipment-characteristics-form/equipment-characteristics-form.component.html + 31,33 + + + src/app/features/organization/features/equipments/ui/pages/equipment-detail-page/equipment-detail-page.component.html + 436,439 + + + + Characteristic + + src/app/features/organization/features/equipments/ui/forms/equipment-characteristics-form/equipment-characteristics-form.component.html + 40,43 + + + + Value + + src/app/features/organization/features/equipments/ui/forms/equipment-characteristics-form/equipment-characteristics-form.component.html + 62,65 + + + + Unit + + src/app/features/organization/features/equipments/ui/forms/equipment-characteristics-form/equipment-characteristics-form.component.html + 84,87 + + + + Remove + + src/app/features/organization/features/equipments/ui/forms/equipment-characteristics-form/equipment-characteristics-form.component.html + 102,104 + + + + Add a characteristic + + src/app/features/organization/features/equipments/ui/forms/equipment-characteristics-form/equipment-characteristics-form.component.html + 115,118 + + + + Save + + src/app/features/organization/features/equipments/ui/forms/equipment-characteristics-form/equipment-characteristics-form.component.html + 137,139 + + + src/app/features/organization/features/facilities/ui/dialogs/facility-plan-calibration-dialog/facility-plan-calibration-dialog.component.html + 105,106 + + + src/app/features/organization/features/facilities/ui/dialogs/facility-plan-pin-position-dialog/facility-plan-pin-position-dialog.component.html + 107,109 + + + src/app/features/organization/features/facilities/ui/dialogs/facility-plan-zone-geometry-dialog/facility-plan-zone-geometry-dialog.component.html + 138,140 + + + src/app/features/organization/features/interventions/ui/forms/intervention-effort-form/intervention-effort-form.component.html + 110,112 + + + src/app/features/organization/features/inventory/ui/forms/inventory-reference-form/inventory-reference-form.component.html + 96,98 + + + src/app/features/organization/features/webhooks/ui/forms/webhook-subscription-form/webhook-subscription-form.component.html + 100,102 + + + src/app/shared/inplace-field/ui/components/inplace-field/inplace-field.component.html + 58,59 + + + + Not specified + + src/app/features/organization/features/equipments/ui/forms/equipment-characteristics-form/equipment-characteristics-form.component.ts + 155 + + + src/app/features/organization/features/equipments/ui/pages/equipment-detail-page/equipment-detail-page.component.ts + 552 + + + + Low + + src/app/features/organization/features/equipments/ui/forms/equipment-characteristics-form/equipment-characteristics-form.component.ts + 156 + + + src/app/features/organization/features/equipments/ui/pages/equipment-detail-page/equipment-detail-page.component.ts + 545 + + + + Medium + + src/app/features/organization/features/equipments/ui/forms/equipment-characteristics-form/equipment-characteristics-form.component.ts + 157 + + + src/app/features/organization/features/equipments/ui/pages/equipment-detail-page/equipment-detail-page.component.ts + 546 + + + + High + + src/app/features/organization/features/equipments/ui/forms/equipment-characteristics-form/equipment-characteristics-form.component.ts + 158 + + + src/app/features/organization/features/equipments/ui/pages/equipment-detail-page/equipment-detail-page.component.ts + 547 + + + + Critical + + src/app/features/organization/features/equipments/ui/forms/equipment-characteristics-form/equipment-characteristics-form.component.ts + 159 + + + src/app/features/organization/features/equipments/ui/pages/equipment-detail-page/equipment-detail-page.component.ts + 548 + + + + Enter a characteristic name. + + src/app/features/organization/features/equipments/ui/forms/equipment-characteristics-form/equipment-characteristics-form.component.ts + 180 + + + src/app/features/organization/features/equipments/ui/forms/equipment-characteristics-form/equipment-characteristics-form.component.ts + 201 + + + + Enter a value. + + src/app/features/organization/features/equipments/ui/forms/equipment-characteristics-form/equipment-characteristics-form.component.ts + 183 + + + src/app/features/organization/features/equipments/ui/forms/equipment-characteristics-form/equipment-characteristics-form.component.ts + 193 + + + + Characteristic names must be unique. + + src/app/features/organization/features/equipments/ui/forms/equipment-characteristics-form/equipment-characteristics-form.component.ts + 207 + + Type @@ -11213,315 +12328,764 @@ 22,23 + + Unique within your organization when supplied. + + src/app/features/organization/features/equipments/ui/forms/equipment-create-form/equipment-create-form.component.html + 71,73 + + Site src/app/features/organization/features/equipments/ui/forms/equipment-create-form/equipment-create-form.component.html - 43,45 + 81,83 Subtype src/app/features/organization/features/equipments/ui/forms/equipment-create-form/equipment-create-form.component.html - 59,62 + 98,101 CO2 src/app/features/organization/features/equipments/ui/forms/equipment-create-form/equipment-create-form.component.html - 67 + 106 Brand src/app/features/organization/features/equipments/ui/forms/equipment-create-form/equipment-create-form.component.html - 75,77 + 114,116 Kidde src/app/features/organization/features/equipments/ui/forms/equipment-create-form/equipment-create-form.component.html - 82 + 121 Model src/app/features/organization/features/equipments/ui/forms/equipment-create-form/equipment-create-form.component.html - 90,92 + 129,131 Pro 210 src/app/features/organization/features/equipments/ui/forms/equipment-create-form/equipment-create-form.component.html - 97 + 136 Serial number src/app/features/organization/features/equipments/ui/forms/equipment-create-form/equipment-create-form.component.html - 106,109 + 145,148 SN-2024-001 src/app/features/organization/features/equipments/ui/forms/equipment-create-form/equipment-create-form.component.html - 113,114 + 152,153 Location src/app/features/organization/features/equipments/ui/forms/equipment-create-form/equipment-create-form.component.html - 125,128 + 164,167 Floor 2 — Near elevator src/app/features/organization/features/equipments/ui/forms/equipment-create-form/equipment-create-form.component.html - 132,133 + 171,172 Creating… src/app/features/organization/features/equipments/ui/forms/equipment-create-form/equipment-create-form.component.html - 164,166 + 203,204 Create equipment src/app/features/organization/features/equipments/ui/forms/equipment-create-form/equipment-create-form.component.html - 166,169 + 207,210 Equipment type is required. src/app/features/organization/features/equipments/ui/forms/equipment-create-form/equipment-create-form.component.ts - 322 + 372 The equipment could not be registered. src/app/features/organization/features/equipments/ui/forms/equipment-create-form/equipment-create-form.component.ts - 358 + 396 + + + + Equipment type + + src/app/features/organization/features/equipments/ui/forms/equipment-type-form/equipment-type-form.component.html + 3,5 + + + + Permanent code + + src/app/features/organization/features/equipments/ui/forms/equipment-type-form/equipment-type-form.component.html + 7,9 + + + src/app/features/organization/features/equipments/ui/pages/equipment-types-page/equipment-types-page.component.html + 93,94 + + + + The code cannot be changed after creation. + + src/app/features/organization/features/equipments/ui/forms/equipment-type-form/equipment-type-form.component.html + 11,13 + + + + Name + + src/app/features/organization/features/equipments/ui/forms/equipment-type-form/equipment-type-form.component.html + 21,23 + + + src/app/features/organization/features/equipments/ui/pages/equipment-types-page/equipment-types-page.component.html + 92,93 + + + + Family + + src/app/features/organization/features/equipments/ui/forms/equipment-type-form/equipment-type-form.component.html + 32,34 + + + src/app/features/organization/features/equipments/ui/pages/equipment-types-page/equipment-types-page.component.html + 94,95 + + + + Fire + + src/app/features/organization/features/equipments/ui/forms/equipment-type-form/equipment-type-form.component.html + 49,51 + + + src/app/features/organization/features/equipments/ui/pages/equipment-types-page/equipment-types-page.component.ts + 52 + + + + Safety + + src/app/features/organization/features/equipments/ui/forms/equipment-type-form/equipment-type-form.component.html + 57,59 + + + src/app/features/organization/features/equipments/ui/pages/equipment-types-page/equipment-types-page.component.ts + 53 + + + + Other + + src/app/features/organization/features/equipments/ui/forms/equipment-type-form/equipment-type-form.component.html + 65,67 + + + src/app/features/organization/features/equipments/ui/pages/equipment-types-page/equipment-types-page.component.ts + 54 + + + + Equipment type could not be saved + + src/app/features/organization/features/equipments/ui/forms/equipment-type-form/equipment-type-form.component.html + 74,76 + + + + Use 1–32 lowercase letters, digits or underscores, starting with a letter. + + src/app/features/organization/features/equipments/ui/forms/equipment-type-form/equipment-type-form.component.ts + 192 + + + + Enter an equipment type name. + + src/app/features/organization/features/equipments/ui/forms/equipment-type-form/equipment-type-form.component.ts + 201 + + + + Use at most 100 characters for the name. + + src/app/features/organization/features/equipments/ui/forms/equipment-type-form/equipment-type-form.component.ts + 205 + + + + Choose an equipment family. + + src/app/features/organization/features/equipments/ui/forms/equipment-type-form/equipment-type-form.component.ts + 212 + + + + Save type + + src/app/features/organization/features/equipments/ui/forms/equipment-type-form/equipment-type-form.component.ts + 245 + + + + Create type + + src/app/features/organization/features/equipments/ui/forms/equipment-type-form/equipment-type-form.component.ts + 246 Export equipment sheet src/app/features/organization/features/equipments/ui/pages/equipment-detail-page/equipment-detail-page.component.html - 26,30 + 21,25 + + + + Declared operational status + + src/app/features/organization/features/equipments/ui/pages/equipment-detail-page/equipment-detail-page.component.html + 28,30 + + + + Control follow-up + + src/app/features/organization/features/equipments/ui/pages/equipment-detail-page/equipment-detail-page.component.html + 34,36 + + + + Next control + + src/app/features/organization/features/equipments/ui/pages/equipment-detail-page/equipment-detail-page.component.html + 44,45 + + + + Open the last control + + src/app/features/organization/features/equipments/ui/pages/equipment-detail-page/equipment-detail-page.component.html + 57,60 + + + + Open anomalies + + src/app/features/organization/features/equipments/ui/pages/equipment-detail-page/equipment-detail-page.component.html + 65,67 + + + + Inspection access required + + src/app/features/organization/features/equipments/ui/pages/equipment-detail-page/equipment-detail-page.component.html + 69,71 + + + + Unavailable + + src/app/features/organization/features/equipments/ui/pages/equipment-detail-page/equipment-detail-page.component.html + 75,77 + + + src/app/features/organization/features/equipments/ui/pages/equipment-detail-page/equipment-detail-page.component.html + 104,107 + + + + Report a defect + + src/app/features/organization/features/equipments/ui/pages/equipment-detail-page/equipment-detail-page.component.html + 116,119 + + + + Request a repair + + src/app/features/organization/features/equipments/ui/pages/equipment-detail-page/equipment-detail-page.component.html + 130,133 Facility deleted or unavailable src/app/features/organization/features/equipments/ui/pages/equipment-detail-page/equipment-detail-page.component.html - 42,45 + 149,152 src/app/features/organization/features/equipments/ui/tables/equipment-table/equipment-table.component.html - 115,118 + 118,121 src/app/features/organization/features/equipments/ui/tables/equipment-table/equipment-table.component.html - 165,168 + 171,174 Unassigned src/app/features/organization/features/equipments/ui/pages/equipment-detail-page/equipment-detail-page.component.html - 46,48 + 153,155 src/app/features/organization/features/equipments/ui/tables/equipment-table/equipment-table.component.html - 129,132 + 132,135 src/app/features/organization/features/equipments/ui/tables/equipment-table/equipment-table.component.html - 179,182 + 185,188 Change the assigned facility src/app/features/organization/features/equipments/ui/pages/equipment-detail-page/equipment-detail-page.component.html - 60,61 + 167,168 src/app/features/organization/features/equipments/ui/pages/equipment-detail-page/equipment-detail-page.component.html - 62,63 + 169,170 Updated src/app/features/organization/features/equipments/ui/pages/equipment-detail-page/equipment-detail-page.component.html - 82,83 + 189,190 Pinned on floor plan src/app/features/organization/features/equipments/ui/pages/equipment-detail-page/equipment-detail-page.component.html - 105,108 + 212,215 Interventions on this equipment's site src/app/features/organization/features/equipments/ui/pages/equipment-detail-page/equipment-detail-page.component.html - 122,124 + 229,231 Equipment sections src/app/features/organization/features/equipments/ui/pages/equipment-detail-page/equipment-detail-page.component.html - 136,137 + 243,244 Overview src/app/features/organization/features/equipments/ui/pages/equipment-detail-page/equipment-detail-page.component.html - 144,146 + 251,253 Attachments src/app/features/organization/features/equipments/ui/pages/equipment-detail-page/equipment-detail-page.component.html - 151,154 + 258,261 Maintenance src/app/features/organization/features/equipments/ui/pages/equipment-detail-page/equipment-detail-page.component.html - 158,160 + 265,267 Tags src/app/features/organization/features/equipments/ui/pages/equipment-detail-page/equipment-detail-page.component.html - 161,162 + 268,269 + + + + Controls and maintenance + + src/app/features/organization/features/equipments/ui/pages/equipment-detail-page/equipment-detail-page.component.html + 285,287 + + + + Intervention access required + + src/app/features/organization/features/equipments/ui/pages/equipment-detail-page/equipment-detail-page.component.html + 289,291 + + + + Open work is unavailable. Retry before preparing a new intervention. + + src/app/features/organization/features/equipments/ui/pages/equipment-detail-page/equipment-detail-page.component.html + 295,297 + + + + Maintenance follow-up + + src/app/features/organization/features/equipments/ui/pages/equipment-detail-page/equipment-detail-page.component.html + 345,347 + + + + Next maintenance + + src/app/features/organization/features/equipments/ui/pages/equipment-detail-page/equipment-detail-page.component.html + 353,354 + + + + View maintenance plans + + src/app/features/organization/features/equipments/ui/pages/equipment-detail-page/equipment-detail-page.component.html + 365,368 Equipment information src/app/features/organization/features/equipments/ui/pages/equipment-detail-page/equipment-detail-page.component.html - 178,180 + 375,377 + + + + Replacement history + + src/app/features/organization/features/equipments/ui/pages/equipment-detail-page/equipment-detail-page.component.html + 451,453 + + + + Open the replaced equipment + + src/app/features/organization/features/equipments/ui/pages/equipment-detail-page/equipment-detail-page.component.html + 460,463 + + + + Open the successor equipment + + src/app/features/organization/features/equipments/ui/pages/equipment-detail-page/equipment-detail-page.component.html + 470,473 Loading equipment src/app/features/organization/features/equipments/ui/pages/equipment-detail-page/equipment-detail-page.component.html - 236,237 + 522,523 This equipment could not be opened src/app/features/organization/features/equipments/ui/pages/equipment-detail-page/equipment-detail-page.component.html - 259,261 + 545,547 More actions src/app/features/organization/features/equipments/ui/pages/equipment-detail-page/equipment-detail-page.component.html - 343,346 + 660,663 + + + + Replace equipment + + src/app/features/organization/features/equipments/ui/pages/equipment-detail-page/equipment-detail-page.component.html + 673,675 + + + src/app/features/organization/features/equipments/ui/sheets/equipment-replacement-sheet/equipment-replacement-sheet.component.html + 13,14 Decommission src/app/features/organization/features/equipments/ui/pages/equipment-detail-page/equipment-detail-page.component.html - 356,358 + 683,685 + + + + Prepare a control + + src/app/features/organization/features/equipments/ui/pages/equipment-detail-page/equipment-detail-page.component.ts + 357 + + + + Prepare maintenance + + src/app/features/organization/features/equipments/ui/pages/equipment-detail-page/equipment-detail-page.component.ts + 361 + + + + Organize a repair + + src/app/features/organization/features/equipments/ui/pages/equipment-detail-page/equipment-detail-page.component.ts + 363 Commission src/app/features/organization/features/equipments/ui/pages/equipment-detail-page/equipment-detail-page.component.ts - 627 + 964 Resume service src/app/features/organization/features/equipments/ui/pages/equipment-detail-page/equipment-detail-page.component.ts - 632 + 969 Maintenance src/app/features/organization/features/equipments/ui/pages/equipment-detail-page/equipment-detail-page.component.ts - 637 + 974 Couldn't export the equipment sheet. src/app/features/organization/features/equipments/ui/pages/equipment-detail-page/equipment-detail-page.component.ts - 1189 + 1746 - - Search equipments... + + Manage the names and families available in your organization's inventory. - src/app/features/organization/features/equipments/ui/pages/equipments-page/equipments-page.component.html - 14,15 + src/app/features/organization/features/equipments/ui/pages/equipment-types-page/equipment-types-page.component.html + 5,8 + + + New equipment type - src/app/features/organization/features/equipments/ui/pages/equipments-page/equipments-page.component.html - 16,17 + src/app/features/organization/features/equipments/ui/pages/equipment-types-page/equipment-types-page.component.html + 16,19 - - - Filters - src/app/features/organization/features/equipments/ui/pages/equipments-page/equipments-page.component.html - 27 + src/app/features/organization/features/equipments/ui/pages/equipment-types-page/equipment-types-page.component.ts + 328 - - Print QR labels + + Equipment types are managed online - src/app/features/organization/features/equipments/ui/pages/equipments-page/equipments-page.component.html - 49,51 + src/app/features/organization/features/equipments/ui/pages/equipment-types-page/equipment-types-page.component.html + 25,27 - - Preparing the label sheet… + + Reconnect to save changes. Your draft is preserved. - src/app/features/organization/features/equipments/ui/pages/equipments-page/equipments-page.component.html - 58,62 + src/app/features/organization/features/equipments/ui/pages/equipment-types-page/equipment-types-page.component.html + 28,32 - - Export + + Equipment type status - src/app/features/organization/features/equipments/ui/pages/equipments-page/equipments-page.component.html - 77,79 + src/app/features/organization/features/equipments/ui/pages/equipment-types-page/equipment-types-page.component.html + 38,39 + + + + Active + + src/app/features/organization/features/equipments/ui/pages/equipment-types-page/equipment-types-page.component.html + 42,44 + + + + Archived + + src/app/features/organization/features/equipments/ui/pages/equipment-types-page/equipment-types-page.component.html + 45,47 + + + + Equipment types could not be loaded + + src/app/features/organization/features/equipments/ui/pages/equipment-types-page/equipment-types-page.component.html + 51,53 + + + + Equipment type could not be updated + + src/app/features/organization/features/equipments/ui/pages/equipment-types-page/equipment-types-page.component.html + 69,71 + + + + Reload catalogue + + src/app/features/organization/features/equipments/ui/pages/equipment-types-page/equipment-types-page.component.html + 80,84 + + + + Actions + + src/app/features/organization/features/equipments/ui/pages/equipment-types-page/equipment-types-page.component.html + 95,98 + + + + Restore + + src/app/features/organization/features/equipments/ui/pages/equipment-types-page/equipment-types-page.component.html + 132,134 + + + + Archive + + src/app/features/organization/features/equipments/ui/pages/equipment-types-page/equipment-types-page.component.html + 134,136 + + + + No equipment types in this view + + src/app/features/organization/features/equipments/ui/pages/equipment-types-page/equipment-types-page.component.html + 148,149 + + + + Choose another view or create an equipment type. Archived types remain available in equipment histories. + + src/app/features/organization/features/equipments/ui/pages/equipment-types-page/equipment-types-page.component.html + 150,153 + + + + Keep stable codes for equipment records and choose the family used to filter your inventory. + + src/app/features/organization/features/equipments/ui/pages/equipment-types-page/equipment-types-page.component.html + 172,175 + + + + This type has changed + + src/app/features/organization/features/equipments/ui/pages/equipment-types-page/equipment-types-page.component.html + 179,180 + + + + Reload the latest revision, compare its name and family with your draft, then save again. Your entered values are preserved. + + src/app/features/organization/features/equipments/ui/pages/equipment-types-page/equipment-types-page.component.html + 181,184 + + + + Review latest revision + + src/app/features/organization/features/equipments/ui/pages/equipment-types-page/equipment-types-page.component.html + 192,194 + + + + Reviewed server values: + + src/app/features/organization/features/equipments/ui/pages/equipment-types-page/equipment-types-page.component.html + 198,199 + + + + Edit equipment type + + src/app/features/organization/features/equipments/ui/pages/equipment-types-page/equipment-types-page.component.ts + 327 + + + + Search equipments... + + src/app/features/organization/features/equipments/ui/pages/equipments-page/equipments-page.component.html + 14,15 + + + src/app/features/organization/features/equipments/ui/pages/equipments-page/equipments-page.component.html + 16,17 + + + + Filters + + src/app/features/organization/features/equipments/ui/pages/equipments-page/equipments-page.component.html + 27 + + + + Print QR labels + + src/app/features/organization/features/equipments/ui/pages/equipments-page/equipments-page.component.html + 49,51 + + + + Preparing the label sheet… + + src/app/features/organization/features/equipments/ui/pages/equipments-page/equipments-page.component.html + 58,62 + + + + Export + + src/app/features/organization/features/equipments/ui/pages/equipments-page/equipments-page.component.html + 77,79 @@ -11560,7 +13124,7 @@ src/app/features/organization/features/equipments/ui/pages/equipments-page/equipments-page.component.ts - 654 + 672 @@ -11585,7 +13149,7 @@ src/app/features/organization/features/equipments/ui/pages/equipments-page/equipments-page.component.ts - 660 + 678 @@ -11652,41 +13216,41 @@ src/app/features/organization/features/equipments/ui/pages/equipments-page/equipments-page.component.html - 248,250 + 258,260 src/app/features/organization/features/equipments/ui/pages/equipments-page/equipments-page.component.html - 253,257 + 263,267 src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 629,631 + 719,721 src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 1591,1592 + 1700,1701 src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 1598,1602 + 1707,1711 src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.ts - 605 + 613 The export always covers every equipment — the active filters and search were ignored. src/app/features/organization/features/equipments/ui/pages/equipments-page/equipments-page.component.ts - 1264 + 1283 Couldn't export equipment. src/app/features/organization/features/equipments/ui/pages/equipments-page/equipments-page.component.ts - 1285 + 1304 @@ -11703,6 +13267,55 @@ 16,18 + + will be retired. Its history remains accessible and the successor inherits its site and position. + + src/app/features/organization/features/equipments/ui/sheets/equipment-replacement-sheet/equipment-replacement-sheet.component.html + 15,17 + + + + The response was lost. Retry the same operation to recover its result without creating a second replacement. + + src/app/features/organization/features/equipments/ui/sheets/equipment-replacement-sheet/equipment-replacement-sheet.component.html + 24,28 + + + + From reserve + + src/app/features/organization/features/equipments/ui/sheets/equipment-replacement-sheet/equipment-replacement-sheet.component.html + 54,57 + + + + New equipment + + src/app/features/organization/features/equipments/ui/sheets/equipment-replacement-sheet/equipment-replacement-sheet.component.html + 62,64 + + + + Successor equipment + + src/app/features/organization/features/equipments/ui/sheets/equipment-replacement-sheet/equipment-replacement-sheet.component.html + 83,86 + + + + Search reserve equipment + + src/app/features/organization/features/equipments/ui/sheets/equipment-replacement-sheet/equipment-replacement-sheet.component.html + 94,95 + + + + Select the successor equipment. + + src/app/features/organization/features/equipments/ui/sheets/equipment-replacement-sheet/equipment-replacement-sheet.component.ts + 389 + + Equipments @@ -11732,7 +13345,7 @@ - Maintenance + Controls src/app/features/organization/features/equipments/ui/tables/equipment-table/equipment-table.component.html 54,55 @@ -11749,21 +13362,21 @@ No results. src/app/features/organization/features/equipments/ui/tables/equipment-table/equipment-table.component.html - 210,213 + 216,219 1 more tag src/app/features/organization/features/equipments/ui/tables/equipment-table/equipment-table.component.ts - 237 + 325 more tags src/app/features/organization/features/equipments/ui/tables/equipment-table/equipment-table.component.ts - 238 + 326 @@ -11936,7 +13549,7 @@ src/app/features/organization/features/interventions/models/intervention-tag/intervention-tag.util.ts - 259 + 316 @@ -11947,7 +13560,7 @@ src/app/features/organization/features/interventions/models/intervention-tag/intervention-tag.util.ts - 264 + 321 @@ -12161,7 +13774,7 @@ Facility moved src/app/features/organization/features/facilities/state/facility-tree/facility-tree.store.ts - 601 + 611 src/app/features/organization/features/facilities/state/facility/facility.store.ts @@ -12172,7 +13785,7 @@ Facility duplicated src/app/features/organization/features/facilities/state/facility-tree/facility-tree.store.ts - 694 + 704 @@ -12443,210 +14056,233 @@ 38,41 + + Customer + + src/app/features/organization/features/facilities/ui/components/facility-information-panel/facility-information-panel.component.html + 47,48 + + + src/app/features/organization/features/facilities/ui/forms/facility-create-form/facility-create-form.component.html + 94,96 + + + src/app/features/organization/features/service-requests/ui/pages/service-request-detail-page/service-request-detail-page.component.html + 197,198 + + + src/app/features/organization/ui/components/organization-park-queues/organization-park-queues.component.html + 31,32 + + + src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html + 26,28 + + Name src/app/features/organization/features/facilities/ui/components/facility-information-panel/facility-information-panel.component.html - 48 + 83 Code src/app/features/organization/features/facilities/ui/components/facility-information-panel/facility-information-panel.component.html - 76 + 111 Not specified src/app/features/organization/features/facilities/ui/components/facility-information-panel/facility-information-panel.component.html - 90,94 + 125,129 src/app/features/organization/features/facilities/ui/components/facility-information-panel/facility-information-panel.component.html - 125,127 + 160,162 src/app/features/organization/features/facilities/ui/components/facility-information-panel/facility-information-panel.component.html - 195,197 + 230,232 src/app/features/organization/features/facilities/ui/components/facility-information-panel/facility-information-panel.component.html - 244,248 + 279,283 src/app/features/organization/features/facilities/ui/components/facility-information-panel/facility-information-panel.component.html - 281,285 + 316,320 Stacking order src/app/features/organization/features/facilities/ui/components/facility-information-panel/facility-information-panel.component.html - 110,111 + 145,146 src/app/features/organization/features/facilities/ui/components/facility-information-panel/facility-information-panel.component.html - 134,136 + 169,171 Where this floor stacks among its siblings — ground floor is 0, a basement level is negative. Ties between floors are allowed. src/app/features/organization/features/facilities/ui/components/facility-information-panel/facility-information-panel.component.html - 168,170 + 203,205 Location src/app/features/organization/features/facilities/ui/components/facility-information-panel/facility-information-panel.component.html - 222,225 + 257,260 Address src/app/features/organization/features/facilities/ui/components/facility-information-panel/facility-information-panel.component.html - 230 + 265 Coordinates src/app/features/organization/features/facilities/ui/components/facility-information-panel/facility-information-panel.component.html - 263,264 + 298,299 Pick on map src/app/features/organization/features/facilities/ui/components/facility-information-panel/facility-information-panel.component.html - 296,298 + 331,333 src/app/features/organization/features/facilities/ui/forms/facility-create-form/facility-create-form.component.html - 232,234 + 248,250 Locate address src/app/features/organization/features/facilities/ui/components/facility-information-panel/facility-information-panel.component.html - 311,313 + 346,348 src/app/features/organization/features/facilities/ui/forms/facility-create-form/facility-create-form.component.html - 204,207 + 220,223 Locating address… src/app/features/organization/features/facilities/ui/components/facility-information-panel/facility-information-panel.component.html - 318,320 + 353,355 src/app/features/organization/features/facilities/ui/forms/facility-create-form/facility-create-form.component.html - 210,212 + 226,228 Address not found — you can still enter the coordinates yourself. src/app/features/organization/features/facilities/ui/components/facility-information-panel/facility-information-panel.component.html - 322,324 + 357,359 src/app/features/organization/features/facilities/ui/forms/facility-create-form/facility-create-form.component.html - 214,216 + 230,232 Or type them, if you already know the figures. Both are needed — a latitude alone is not half a location. src/app/features/organization/features/facilities/ui/components/facility-information-panel/facility-information-panel.component.html - 329,332 + 364,367 Latitude src/app/features/organization/features/facilities/ui/components/facility-information-panel/facility-information-panel.component.html - 338,340 + 373,375 src/app/features/organization/features/facilities/ui/components/facility-information-panel/facility-information-panel.component.html - 347,348 + 382,383 src/app/features/organization/features/facilities/ui/forms/facility-create-form/facility-create-form.component.html - 242,245 + 258,261 Longitude src/app/features/organization/features/facilities/ui/components/facility-information-panel/facility-information-panel.component.html - 360,362 + 395,397 src/app/features/organization/features/facilities/ui/components/facility-information-panel/facility-information-panel.component.html - 369,370 + 404,405 src/app/features/organization/features/facilities/ui/forms/facility-create-form/facility-create-form.component.html - 261,264 + 277,280 Elevation (m) src/app/features/organization/features/facilities/ui/components/facility-information-panel/facility-information-panel.component.ts - 331 + 411 Height (m) src/app/features/organization/features/facilities/ui/components/facility-information-panel/facility-information-panel.component.ts - 332 + 412 Enter a valid value in metres: elevation from -10000 to 10000, height greater than 0 and no more than 1000. src/app/features/organization/features/facilities/ui/components/facility-information-panel/facility-information-panel.component.ts - 361 + 441 Enter both latitude and longitude, or leave both empty. src/app/features/organization/features/facilities/ui/components/facility-information-panel/facility-information-panel.component.ts - 494 + 574 Latitude runs from -90 to 90, longitude from -180 to 180. src/app/features/organization/features/facilities/ui/components/facility-information-panel/facility-information-panel.component.ts - 510 + 590 Enter a whole number between -100 and 200. src/app/features/organization/features/facilities/ui/components/facility-information-panel/facility-information-panel.component.ts - 542 + 622 View parent facility src/app/features/organization/features/facilities/ui/components/facility-information-panel/facility-information-panel.component.ts - 580 + 660 @@ -13455,7 +15091,7 @@ src/app/features/organization/features/facilities/ui/forms/facility-create-form/facility-create-form.component.ts - 623 + 653 @@ -13500,33 +15136,6 @@ 85,87 - - Save - - src/app/features/organization/features/facilities/ui/dialogs/facility-plan-calibration-dialog/facility-plan-calibration-dialog.component.html - 105,106 - - - src/app/features/organization/features/facilities/ui/dialogs/facility-plan-pin-position-dialog/facility-plan-pin-position-dialog.component.html - 107,109 - - - src/app/features/organization/features/facilities/ui/dialogs/facility-plan-zone-geometry-dialog/facility-plan-zone-geometry-dialog.component.html - 138,140 - - - src/app/features/organization/features/interventions/ui/forms/intervention-effort-form/intervention-effort-form.component.html - 110,112 - - - src/app/features/organization/features/webhooks/ui/forms/webhook-subscription-form/webhook-subscription-form.component.html - 100,102 - - - src/app/shared/inplace-field/ui/components/inplace-field/inplace-field.component.html - 58,59 - - Enter an image width greater than 0 and no more than 100000 metres. @@ -13808,204 +15417,211 @@ 68,70 + + Optional. Assign a customer to this root site, or manage it directly as an operator. + + src/app/features/organization/features/facilities/ui/forms/facility-create-form/facility-create-form.component.html + 102,104 + + Stacking order src/app/features/organization/features/facilities/ui/forms/facility-create-form/facility-create-form.component.html - 97,100 + 113,116 Where this floor stacks among its siblings — ground floor is 0, a basement level is negative. Ties between floors are allowed. src/app/features/organization/features/facilities/ui/forms/facility-create-form/facility-create-form.component.html - 115,117 + 131,133 Elevation (m) src/app/features/organization/features/facilities/ui/forms/facility-create-form/facility-create-form.component.html - 124,127 + 140,143 Height (m) src/app/features/organization/features/facilities/ui/forms/facility-create-form/facility-create-form.component.html - 143,146 + 159,162 Code src/app/features/organization/features/facilities/ui/forms/facility-create-form/facility-create-form.component.html - 163,165 + 179,181 BLD-001 src/app/features/organization/features/facilities/ui/forms/facility-create-form/facility-create-form.component.html - 170 + 186 Address src/app/features/organization/features/facilities/ui/forms/facility-create-form/facility-create-form.component.html - 179,181 + 195,197 123 Main Street, City src/app/features/organization/features/facilities/ui/forms/facility-create-form/facility-create-form.component.html - 188,189 + 204,205 Placing the pin fills both figures below — typing them is the fallback, not the way in. src/app/features/organization/features/facilities/ui/forms/facility-create-form/facility-create-form.component.html - 235,239 + 251,255 Creating… src/app/features/organization/features/facilities/ui/forms/facility-create-form/facility-create-form.component.html - 301,303 + 317,319 Create facility src/app/features/organization/features/facilities/ui/forms/facility-create-form/facility-create-form.component.html - 303,306 + 319,322 Sites are created at the root of the hierarchy. src/app/features/organization/features/facilities/ui/forms/facility-create-form/facility-create-form.component.ts - 347 + 377 Choose the site containing this building. src/app/features/organization/features/facilities/ui/forms/facility-create-form/facility-create-form.component.ts - 349 + 379 Choose the building containing this floor. src/app/features/organization/features/facilities/ui/forms/facility-create-form/facility-create-form.component.ts - 351 + 381 Choose a type to see its admissible parents. src/app/features/organization/features/facilities/ui/forms/facility-create-form/facility-create-form.component.ts - 353 + 383 Choose the site, building, floor, zone or area containing this place. src/app/features/organization/features/facilities/ui/forms/facility-create-form/facility-create-form.component.ts - 354 + 384 A site must be at the root of the hierarchy. src/app/features/organization/features/facilities/ui/forms/facility-create-form/facility-create-form.component.ts - 618 + 648 This parent cannot contain the selected type. src/app/features/organization/features/facilities/ui/forms/facility-create-form/facility-create-form.component.ts - 631 + 661 Enter an elevation between -10000 and 10000 metres. src/app/features/organization/features/facilities/ui/forms/facility-create-form/facility-create-form.component.ts - 640 + 670 Enter a height greater than 0 and no more than 1000 metres. src/app/features/organization/features/facilities/ui/forms/facility-create-form/facility-create-form.component.ts - 650 + 680 Facility type is required. src/app/features/organization/features/facilities/ui/forms/facility-create-form/facility-create-form.component.ts - 654 + 684 Name is required. src/app/features/organization/features/facilities/ui/forms/facility-create-form/facility-create-form.component.ts - 657 + 687 Enter both latitude and longitude, or leave both empty. src/app/features/organization/features/facilities/ui/forms/facility-create-form/facility-create-form.component.ts - 668 + 698 src/app/features/organization/features/facilities/ui/forms/facility-create-form/facility-create-form.component.ts - 687 + 717 Enter a latitude between -90 and 90. src/app/features/organization/features/facilities/ui/forms/facility-create-form/facility-create-form.component.ts - 676 + 706 Enter a longitude between -180 and 180. src/app/features/organization/features/facilities/ui/forms/facility-create-form/facility-create-form.component.ts - 695 + 725 Enter a whole number between -100 and 200. src/app/features/organization/features/facilities/ui/forms/facility-create-form/facility-create-form.component.ts - 703 + 733 The facility could not be created. src/app/features/organization/features/facilities/ui/forms/facility-create-form/facility-create-form.component.ts - 740 + 770 Couldn't create the facility src/app/features/organization/features/facilities/ui/forms/facility-create-form/facility-create-form.component.ts - 756 + 786 @@ -14309,27 +15925,27 @@ src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 125,127 + 173,175 src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 1573,1574 + 1682,1683 src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 1580,1585 + 1689,1694 src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 1611,1612 + 1720,1721 src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 1627,1629 + 1736,1738 src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.ts - 591 + 599 @@ -15044,7 +16660,7 @@ src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.ts - 1096 + 1104 @@ -15055,7 +16671,7 @@ src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.ts - 1097 + 1105 @@ -15325,7 +16941,7 @@ src/app/features/organization/navigation/organization-navigation.config.ts - 479 + 524 @@ -16077,7 +17693,7 @@ src/app/features/organization/navigation/organization-navigation.config.ts - 431 + 484 @@ -16099,7 +17715,7 @@ src/app/features/organization/features/interventions/models/intervention-tag/intervention-tag.util.ts - 235 + 287 @@ -16110,7 +17726,7 @@ src/app/features/organization/features/interventions/models/intervention-tag/intervention-tag.util.ts - 240 + 292 @@ -16121,7 +17737,7 @@ src/app/features/organization/features/interventions/models/intervention-tag/intervention-tag.util.ts - 245 + 297 @@ -16132,7 +17748,7 @@ src/app/features/organization/features/interventions/models/intervention-tag/intervention-tag.util.ts - 250 + 302 @@ -16143,7 +17759,7 @@ src/app/features/organization/features/interventions/models/intervention-tag/intervention-tag.util.ts - 216 + 263 @@ -16154,7 +17770,7 @@ src/app/features/organization/features/interventions/models/intervention-tag/intervention-tag.util.ts - 221 + 268 @@ -16165,7 +17781,7 @@ src/app/features/organization/features/interventions/models/intervention-tag/intervention-tag.util.ts - 226 + 273 @@ -16305,7 +17921,7 @@ src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.ts - 563 + 571 @@ -16389,60 +18005,74 @@ 42,47 + + Open the source inspection + + src/app/features/organization/features/inspections/ui/dataviews/non-conformity-list/non-conformity-list.component.html + 64,68 + + Overdue src/app/features/organization/features/inspections/ui/dataviews/non-conformity-list/non-conformity-list.component.html - 71,73 + 82,84 Due src/app/features/organization/features/inspections/ui/dataviews/non-conformity-list/non-conformity-list.component.html - 74,76 + 85,87 Resolved src/app/features/organization/features/inspections/ui/dataviews/non-conformity-list/non-conformity-list.component.html - 84,86 + 95,97 Waiver pending approval src/app/features/organization/features/inspections/ui/dataviews/non-conformity-list/non-conformity-list.component.html - 107,109 + 118,120 This non-conformity stays open until a reviewer decides the request. src/app/features/organization/features/inspections/ui/dataviews/non-conformity-list/non-conformity-list.component.html - 111,114 + 122,125 View in approvals src/app/features/organization/features/inspections/ui/dataviews/non-conformity-list/non-conformity-list.component.html - 120,123 + 131,134 + + + + Open source inspection for + + src/app/features/organization/features/inspections/ui/dataviews/non-conformity-list/non-conformity-list.component.ts + 196 Status for src/app/features/organization/features/inspections/ui/dataviews/non-conformity-list/non-conformity-list.component.ts - 631 + 668 View in approvals for src/app/features/organization/features/inspections/ui/dataviews/non-conformity-list/non-conformity-list.component.ts - 671 + 708 @@ -17570,6 +19200,13 @@ 191,194 + + Parts used + + src/app/features/organization/features/interventions/constants/intervention-outbox-label.constants.ts + 18 + + Time recorded @@ -17668,279 +19305,342 @@ 32 + + Save this intervention on the device before preparing its stock references. + + src/app/features/organization/features/interventions/data-access/services/intervention-offline/intervention-inventory.repository.ts + 135 + + + + This intervention must be saved on this device before a consumption can be queued. Retry preparing the workspace. + + src/app/features/organization/features/interventions/data-access/services/intervention-offline/intervention-outbox.repository.ts + 345 + + + + This declaration identifier already belongs to another physical fact. Keep the original declaration and review it before continuing. + + src/app/features/organization/features/interventions/data-access/services/intervention-offline/intervention-outbox.repository.ts + 357 + + + + Review the conflicted declaration before retrying it. + + src/app/features/organization/features/interventions/data-access/services/intervention-offline/intervention-outbox.repository.ts + 361 + + Interventions src/app/features/organization/features/interventions/interventions.routes.ts - 115 + 117 src/app/features/organization/features/interventions/interventions.routes.ts - 124 + 126 src/app/features/organization/navigation/organization-navigation.config.ts - 406 + 415 Low src/app/features/organization/features/interventions/models/intervention-tag/intervention-tag.util.ts - 27 + 29 Normal src/app/features/organization/features/interventions/models/intervention-tag/intervention-tag.util.ts - 32 + 34 High src/app/features/organization/features/interventions/models/intervention-tag/intervention-tag.util.ts - 37 + 39 Urgent src/app/features/organization/features/interventions/models/intervention-tag/intervention-tag.util.ts - 42 + 44 Draft src/app/features/organization/features/interventions/models/intervention-tag/intervention-tag.util.ts - 58 + 62 Planned src/app/features/organization/features/interventions/models/intervention-tag/intervention-tag.util.ts - 63 + 67 In progress src/app/features/organization/features/interventions/models/intervention-tag/intervention-tag.util.ts - 68 + 72 Submitted src/app/features/organization/features/interventions/models/intervention-tag/intervention-tag.util.ts - 73 + 77 Changes requested src/app/features/organization/features/interventions/models/intervention-tag/intervention-tag.util.ts - 78 + 82 Published src/app/features/organization/features/interventions/models/intervention-tag/intervention-tag.util.ts - 83 + 87 Abandoned src/app/features/organization/features/interventions/models/intervention-tag/intervention-tag.util.ts - 88 + 92 Site setup src/app/features/organization/features/interventions/models/intervention-tag/intervention-tag.util.ts - 102 + 108 src/app/features/organization/features/interventions/models/intervention-tag/intervention-tag.util.ts - 125 + 143 Inventory src/app/features/organization/features/interventions/models/intervention-tag/intervention-tag.util.ts - 107 + 113 src/app/features/organization/features/interventions/models/intervention-tag/intervention-tag.util.ts - 130 + 148 Inspection campaign src/app/features/organization/features/interventions/models/intervention-tag/intervention-tag.util.ts - 112 + 118 + + + + Preventive maintenance + + src/app/features/organization/features/interventions/models/intervention-tag/intervention-tag.util.ts + 123 + + + + Corrective maintenance + + src/app/features/organization/features/interventions/models/intervention-tag/intervention-tag.util.ts + 128 Inspection src/app/features/organization/features/interventions/models/intervention-tag/intervention-tag.util.ts - 135 + 153 + + + + Maintenance + + src/app/features/organization/features/interventions/models/intervention-tag/intervention-tag.util.ts + 158 + + + + Repair + + src/app/features/organization/features/interventions/models/intervention-tag/intervention-tag.util.ts + 163 + + + + Replacement + + src/app/features/organization/features/interventions/models/intervention-tag/intervention-tag.util.ts + 168 Planned src/app/features/organization/features/interventions/models/intervention-tag/intervention-tag.util.ts - 149 + 184 In progress src/app/features/organization/features/interventions/models/intervention-tag/intervention-tag.util.ts - 154 + 189 Completed src/app/features/organization/features/interventions/models/intervention-tag/intervention-tag.util.ts - 159 + 194 Skipped src/app/features/organization/features/interventions/models/intervention-tag/intervention-tag.util.ts - 164 + 199 Blocker src/app/features/organization/features/interventions/models/intervention-tag/intervention-tag.util.ts - 173 + 213 Warning src/app/features/organization/features/interventions/models/intervention-tag/intervention-tag.util.ts - 178 + 218 Recommendation src/app/features/organization/features/interventions/models/intervention-tag/intervention-tag.util.ts - 183 + 223 Proposed src/app/features/organization/features/interventions/models/intervention-tag/intervention-tag.util.ts - 197 + 239 Rejected src/app/features/organization/features/interventions/models/intervention-tag/intervention-tag.util.ts - 202 + 244 Applied src/app/features/organization/features/interventions/models/intervention-tag/intervention-tag.util.ts - 207 + 249 Already overdue src/app/features/organization/features/interventions/options/intervention-filter-options.constants.ts - 72 + 78 Due today src/app/features/organization/features/interventions/options/intervention-filter-options.constants.ts - 73 + 79 Due within 7 days src/app/features/organization/features/interventions/options/intervention-filter-options.constants.ts - 74 + 80 Due within 30 days src/app/features/organization/features/interventions/options/intervention-filter-options.constants.ts - 75 + 81 Due date src/app/features/organization/features/interventions/options/intervention-filter-options.constants.ts - 88 + 94 Start date src/app/features/organization/features/interventions/options/intervention-filter-options.constants.ts - 89 + 95 Name src/app/features/organization/features/interventions/options/intervention-filter-options.constants.ts - 90 + 96 Creation date src/app/features/organization/features/interventions/options/intervention-filter-options.constants.ts - 91 + 97 Last update src/app/features/organization/features/interventions/options/intervention-filter-options.constants.ts - 92 + 98 Priority src/app/features/organization/features/interventions/options/intervention-filter-options.constants.ts - 93 + 99 Status src/app/features/organization/features/interventions/options/intervention-filter-options.constants.ts - 116 + 121 src/app/features/organization/features/interventions/ui/pages/interventions-page/interventions-page.component.html @@ -17963,7 +19663,7 @@ Type src/app/features/organization/features/interventions/options/intervention-filter-options.constants.ts - 122 + 127 src/app/features/organization/features/interventions/ui/pages/interventions-page/interventions-page.component.html @@ -17978,7 +19678,7 @@ Priority src/app/features/organization/features/interventions/options/intervention-filter-options.constants.ts - 128 + 133 src/app/features/organization/features/interventions/ui/pages/interventions-page/interventions-page.component.html @@ -17993,7 +19693,7 @@ Site src/app/features/organization/features/interventions/options/intervention-filter-options.constants.ts - 134 + 139 src/app/features/organization/features/interventions/ui/pages/interventions-page/interventions-page.component.html @@ -18008,7 +19708,7 @@ Responsible src/app/features/organization/features/interventions/options/intervention-filter-options.constants.ts - 140 + 145 src/app/features/organization/features/interventions/ui/pages/interventions-page/interventions-page.component.html @@ -18023,7 +19723,7 @@ Label src/app/features/organization/features/interventions/options/intervention-filter-options.constants.ts - 146 + 151 src/app/features/organization/features/interventions/ui/pages/interventions-page/interventions-page.component.html @@ -18038,49 +19738,71 @@ Deadline src/app/features/organization/features/interventions/options/intervention-filter-options.constants.ts - 152 + 157 after src/app/features/organization/features/interventions/options/intervention-filter-options.constants.ts - 156 + 161 src/app/features/organization/features/interventions/options/intervention-filter-options.constants.ts - 166 + 171 before src/app/features/organization/features/interventions/options/intervention-filter-options.constants.ts - 157 + 162 src/app/features/organization/features/interventions/options/intervention-filter-options.constants.ts - 167 + 172 Planned start src/app/features/organization/features/interventions/options/intervention-filter-options.constants.ts - 162 + 167 Due window src/app/features/organization/features/interventions/options/intervention-filter-options.constants.ts - 172 + 177 src/app/features/organization/features/interventions/ui/pages/interventions-page/interventions-page.component.html 947,948 + + The declaration history could not be verified for this intervention. Refresh before continuing. + + src/app/features/organization/features/interventions/services/intervention-inventory/intervention-inventory.service.ts + 298 + + + + The stock references changed or could not be downloaded completely. Retry preparation. + + src/app/features/organization/features/interventions/services/intervention-inventory/intervention-inventory.service.ts + 357 + + + src/app/features/organization/features/interventions/services/intervention-inventory/intervention-inventory.service.ts + 369 + + + src/app/features/organization/features/interventions/services/intervention-inventory/intervention-inventory.service.ts + 373 + + Local changes must be synchronized or reviewed before applying this update. @@ -18109,6 +19831,31 @@ 395 + + The server received this declaration, but its receipt could not be saved on this device. Keep the queued declaration and synchronize again. + + src/app/features/organization/features/interventions/services/intervention-sync/models/accepted-inventory-persistence-error.ts + 25 + + + + Correction history is unavailable offline. Reconnect to load earlier versions. + + src/app/features/organization/features/interventions/services/intervention-time-journal/intervention-time-journal.service.ts + 231 + + + src/app/features/organization/features/interventions/ui/sheets/intervention-time-sheet/intervention-time-sheet.component.html + 208,210 + + + + Reconnect to review the current version of this saved correction. + + src/app/features/organization/features/interventions/services/intervention-time-journal/intervention-time-journal.service.ts + 269 + + Label created @@ -18223,28 +19970,28 @@ This intervention has not been saved on this device, so it cannot be opened offline. Reconnect and try again. src/app/features/organization/features/interventions/state/intervention-workspace/intervention-workspace.store.ts - 491 + 527 The intervention workspace could not be loaded. src/app/features/organization/features/interventions/state/intervention-workspace/intervention-workspace.store.ts - 495 + 531 The offline file queue is full (25 files or 50 MB). Reconnect to sync your pending files before adding more. src/app/features/organization/features/interventions/state/intervention-workspace/intervention-workspace.store.ts - 767 + 804 The comment could not be posted. src/app/features/organization/features/interventions/state/intervention-workspace/intervention-workspace.store.ts - 866 + 903 src/app/features/organization/features/interventions/ui/forms/intervention-comment-form/intervention-comment-form.component.ts @@ -18255,14 +20002,14 @@ The intervention status could not be updated. src/app/features/organization/features/interventions/state/intervention-workspace/intervention-workspace.store.ts - 1027 + 1064 This intervention changed since it was loaded. Refresh and try again. src/app/features/organization/features/interventions/state/intervention-workspace/intervention-workspace.store.ts - 1029 + 1066 src/app/features/organization/features/interventions/state/intervention/intervention.store.ts @@ -18273,7 +20020,7 @@ You do not have permission to change this intervention's status. src/app/features/organization/features/interventions/state/intervention-workspace/intervention-workspace.store.ts - 1031 + 1068 src/app/features/organization/features/interventions/state/intervention/intervention.store.ts @@ -18284,7 +20031,7 @@ This status change is not allowed from the intervention's current status. src/app/features/organization/features/interventions/state/intervention-workspace/intervention-workspace.store.ts - 1033 + 1070 src/app/features/organization/features/interventions/state/intervention/intervention.store.ts @@ -18295,85 +20042,85 @@ Intervention planning details could not be saved. src/app/features/organization/features/interventions/state/intervention-workspace/intervention-workspace.store.ts - 1115 + 1152 src/app/features/organization/features/interventions/state/intervention-workspace/intervention-workspace.store.ts - 1166 + 1203 Team assigned src/app/features/organization/features/interventions/state/intervention-workspace/intervention-workspace.store.ts - 1217 + 1254 The team could not be assigned. src/app/features/organization/features/interventions/state/intervention-workspace/intervention-workspace.store.ts - 1225 + 1262 This team has no active members. src/app/features/organization/features/interventions/state/intervention-workspace/intervention-workspace.store.ts - 1227 + 1264 This intervention can no longer be assigned a team; refreshing its current state. src/app/features/organization/features/interventions/state/intervention-workspace/intervention-workspace.store.ts - 1229 + 1266 The work item could not be created. src/app/features/organization/features/interventions/state/intervention-workspace/intervention-workspace.store.ts - 1517 + 1558 src/app/features/organization/features/interventions/ui/forms/intervention-work-item-form/intervention-work-item-form.component.ts - 716 + 868 The work item could not be updated. src/app/features/organization/features/interventions/state/intervention-workspace/intervention-workspace.store.ts - 1655 + 1696 The proposed change could not be rejected. src/app/features/organization/features/interventions/state/intervention-workspace/intervention-workspace.store.ts - 1790 + 1831 Connect to the network to delete planned work items. src/app/features/organization/features/interventions/state/intervention-workspace/intervention-workspace.store.ts - 1833 + 1874 The work item could not be deleted. src/app/features/organization/features/interventions/state/intervention-workspace/intervention-workspace.store.ts - 1887 + 1928 Intervention deleted src/app/features/organization/features/interventions/state/intervention-workspace/intervention-workspace.store.ts - 1929 + 1970 src/app/features/organization/features/interventions/state/intervention/intervention.store.ts @@ -18384,7 +20131,7 @@ The intervention could not be deleted. src/app/features/organization/features/interventions/state/intervention-workspace/intervention-workspace.store.ts - 1935 + 1976 src/app/features/organization/features/interventions/state/intervention/intervention.store.ts @@ -18395,7 +20142,7 @@ Only draft or abandoned interventions can be deleted. src/app/features/organization/features/interventions/state/intervention-workspace/intervention-workspace.store.ts - 1939 + 1980 src/app/features/organization/features/interventions/state/intervention/intervention.store.ts @@ -18406,7 +20153,7 @@ You do not have permission to delete this intervention. src/app/features/organization/features/interventions/state/intervention-workspace/intervention-workspace.store.ts - 1941 + 1982 src/app/features/organization/features/interventions/state/intervention/intervention.store.ts @@ -18417,18 +20164,18 @@ The file could not be uploaded. src/app/features/organization/features/interventions/state/intervention-workspace/intervention-workspace.store.ts - 2069 + 2110 The file could not be deleted. src/app/features/organization/features/interventions/state/intervention-workspace/intervention-workspace.store.ts - 2107 + 2148 src/app/features/organization/features/interventions/state/intervention-workspace/intervention-workspace.store.ts - 2153 + 2194 @@ -18957,6 +20704,45 @@ 44,46 + + Used for + + src/app/features/organization/features/interventions/ui/components/intervention-inventory-context/intervention-inventory-context.component.html + 6,9 + + + + Choose a task + + src/app/features/organization/features/interventions/ui/components/intervention-inventory-context/intervention-inventory-context.component.html + 19,20 + + + + Whole intervention + + src/app/features/organization/features/interventions/ui/components/intervention-inventory-context/intervention-inventory-context.component.html + 29,32 + + + src/app/features/organization/features/interventions/ui/components/intervention-inventory-context/intervention-inventory-context.component.ts + 115 + + + + Selecting a task links the parts used to that work and its equipment. + + src/app/features/organization/features/interventions/ui/components/intervention-inventory-context/intervention-inventory-context.component.html + 41,44 + + + + Work item + + src/app/features/organization/features/interventions/ui/components/intervention-inventory-context/intervention-inventory-context.component.ts + 120 + + Checking publication requirements… @@ -19018,7 +20804,7 @@ src/app/features/organization/features/interventions/ui/pages/interventions-page/interventions-page.component.ts - 3809 + 3870 src/app/features/organization/features/interventions/ui/tables/intervention-table/intervention-table.component.html @@ -19178,7 +20964,7 @@ src/app/features/organization/features/interventions/ui/forms/intervention-work-item-form/intervention-work-item-form.component.html - 123,125 + 134,136 src/app/features/organization/features/interventions/ui/pages/interventions-page/interventions-page.component.html @@ -19493,7 +21279,7 @@ src/app/features/organization/features/interventions/ui/sheets/intervention-operations-sheet/intervention-operations-sheet.component.html - 93,95 + 103,105 @@ -19872,11 +21658,11 @@ src/app/features/organization/features/interventions/ui/tables/intervention-work-item-table/intervention-work-item-table.component.html - 462,463 + 466,467 src/app/features/organization/features/interventions/ui/tables/intervention-work-item-table/intervention-work-item-table.component.html - 677,678 + 685,686 src/app/features/organization/features/webhooks/ui/pages/webhooks-page/webhooks-page.component.html @@ -19891,11 +21677,18 @@ src/app/features/organization/features/interventions/ui/tables/intervention-work-item-table/intervention-work-item-table.component.html - 450,451 + 454,455 src/app/features/organization/features/interventions/ui/tables/intervention-work-item-table/intervention-work-item-table.component.html - 665,666 + 673,674 + + + + Record actual work + + src/app/features/organization/features/interventions/ui/dialogs/intervention-execution-result-dialog/intervention-execution-result-dialog.component.html + 12,13 @@ -20525,25 +22318,25 @@ Give the intervention a name src/app/features/organization/features/interventions/ui/forms/intervention-create-form/intervention-create-form.component.ts - 401 + 403 Use between 2 and 255 characters. src/app/features/organization/features/interventions/ui/forms/intervention-create-form/intervention-create-form.component.ts - 404 + 406 src/app/features/organization/features/interventions/ui/forms/intervention-create-form/intervention-create-form.component.ts - 407 + 409 The intervention could not be created. src/app/features/organization/features/interventions/ui/forms/intervention-create-form/intervention-create-form.component.ts - 459 + 461 src/app/features/organization/features/interventions/ui/sheets/intervention-create-sheet/intervention-create-sheet.component.ts @@ -20643,6 +22436,211 @@ 254 + + Replacement equipment + + src/app/features/organization/features/interventions/ui/forms/intervention-execution-result-form/intervention-execution-result-form.component.html + 11,13 + + + + Checking the equipment replacement… + + src/app/features/organization/features/interventions/ui/forms/intervention-execution-result-form/intervention-execution-result-form.component.html + 15,17 + + + + This published equipment is linked to the retired original. + + src/app/features/organization/features/interventions/ui/forms/intervention-execution-result-form/intervention-execution-result-form.component.html + 22,24 + + + + The replacement could not be checked. Keep your draft and refresh when the equipment dossier is available. + + src/app/features/organization/features/interventions/ui/forms/intervention-execution-result-form/intervention-execution-result-form.component.html + 26,30 + + + + Confirm the replacement in the equipment dossier, then refresh its successor. + + src/app/features/organization/features/interventions/ui/forms/intervention-execution-result-form/intervention-execution-result-form.component.html + 31,34 + + + src/app/features/organization/features/interventions/ui/forms/intervention-execution-result-form/intervention-execution-result-form.component.ts + 358 + + + + Open equipment dossier + + src/app/features/organization/features/interventions/ui/forms/intervention-execution-result-form/intervention-execution-result-form.component.html + 45,49 + + + + This task can no longer be executed in the current workflow. Your draft remains available. + + src/app/features/organization/features/interventions/ui/forms/intervention-execution-result-form/intervention-execution-result-form.component.html + 65,68 + + + + The execution result could not be saved. + + src/app/features/organization/features/interventions/ui/forms/intervention-execution-result-form/intervention-execution-result-form.component.html + 72,74 + + + src/app/features/organization/features/interventions/ui/forms/intervention-execution-result-form/intervention-execution-result-form.component.ts + 390 + + + + Performed at + + src/app/features/organization/features/interventions/ui/forms/intervention-execution-result-form/intervention-execution-result-form.component.html + 83,87 + + + + Date and time in . + + src/app/features/organization/features/interventions/ui/forms/intervention-execution-result-form/intervention-execution-result-form.component.html + 94,96 + + + + Result + + src/app/features/organization/features/interventions/ui/forms/intervention-execution-result-form/intervention-execution-result-form.component.html + 102,104 + + + + Successful + + src/app/features/organization/features/interventions/ui/forms/intervention-execution-result-form/intervention-execution-result-form.component.html + 118,120 + + + src/app/features/organization/features/interventions/ui/forms/intervention-execution-result-form/intervention-execution-result-form.component.ts + 301 + + + src/app/features/organization/features/interventions/ui/sheets/intervention-operations-sheet/intervention-operations-sheet.component.ts + 437 + + + src/app/features/organization/features/interventions/ui/tables/intervention-work-item-table/intervention-work-item-table.component.ts + 1491 + + + + Performed + + src/app/features/organization/features/interventions/ui/forms/intervention-execution-result-form/intervention-execution-result-form.component.html + 122,125 + + + src/app/features/organization/features/interventions/ui/forms/intervention-execution-result-form/intervention-execution-result-form.component.ts + 303 + + + src/app/features/organization/features/interventions/ui/sheets/intervention-operations-sheet/intervention-operations-sheet.component.ts + 441 + + + src/app/features/organization/features/interventions/ui/tables/intervention-work-item-table/intervention-work-item-table.component.ts + 1495 + + + + Unsuccessful — work still required + + src/app/features/organization/features/interventions/ui/forms/intervention-execution-result-form/intervention-execution-result-form.component.html + 126,128 + + + src/app/features/organization/features/interventions/ui/forms/intervention-execution-result-form/intervention-execution-result-form.component.ts + 305 + + + src/app/features/organization/features/interventions/ui/sheets/intervention-operations-sheet/intervention-operations-sheet.component.ts + 439 + + + src/app/features/organization/features/interventions/ui/tables/intervention-work-item-table/intervention-work-item-table.component.ts + 1493 + + + + Work performed + + src/app/features/organization/features/interventions/ui/forms/intervention-execution-result-form/intervention-execution-result-form.component.html + 136,139 + + + + The result is recorded now and validated when the intervention is published. An unsuccessful result keeps the task open. + + src/app/features/organization/features/interventions/ui/forms/intervention-execution-result-form/intervention-execution-result-form.component.html + 150,155 + + + + Record result + + src/app/features/organization/features/interventions/ui/forms/intervention-execution-result-form/intervention-execution-result-form.component.html + 176,178 + + + + Enter when the work was performed. + + src/app/features/organization/features/interventions/ui/forms/intervention-execution-result-form/intervention-execution-result-form.component.ts + 326 + + + + Enter a valid date and time in the displayed timezone. + + src/app/features/organization/features/interventions/ui/forms/intervention-execution-result-form/intervention-execution-result-form.component.ts + 335 + + + + Choose the result of this work. + + src/app/features/organization/features/interventions/ui/forms/intervention-execution-result-form/intervention-execution-result-form.component.ts + 339 + + + + Choose a result appropriate for this action. + + src/app/features/organization/features/interventions/ui/forms/intervention-execution-result-form/intervention-execution-result-form.component.ts + 348 + + + + Describe the work actually performed. + + src/app/features/organization/features/interventions/ui/forms/intervention-execution-result-form/intervention-execution-result-form.component.ts + 366 + + + + Keep the work description within 10,000 characters. + + src/app/features/organization/features/interventions/ui/forms/intervention-execution-result-form/intervention-execution-result-form.component.ts + 370 + + Template and identification @@ -21055,113 +23053,113 @@ Target resource src/app/features/organization/features/interventions/ui/forms/intervention-work-item-form/intervention-work-item-form.component.html - 43,46 + 43,44 - - Optional site, facility or equipment target + + No target matches. src/app/features/organization/features/interventions/ui/forms/intervention-work-item-form/intervention-work-item-form.component.html - 56,57 + 63,65 - - No target matches. + + Select the equipment to keep its maintenance history traceable. src/app/features/organization/features/interventions/ui/forms/intervention-work-item-form/intervention-work-item-form.component.html - 61,63 + 86,88 Leave empty for site-level work. src/app/features/organization/features/interventions/ui/forms/intervention-work-item-form/intervention-work-item-form.component.html - 83,85 + 90,91 Assignee src/app/features/organization/features/interventions/ui/forms/intervention-work-item-form/intervention-work-item-form.component.html - 89,92 + 100,103 Assign this work item src/app/features/organization/features/interventions/ui/forms/intervention-work-item-form/intervention-work-item-form.component.html - 99,100 + 110,111 Unassigned items can be picked up by any field agent. src/app/features/organization/features/interventions/ui/forms/intervention-work-item-form/intervention-work-item-form.component.html - 155,157 + 166,168 Estimated time src/app/features/organization/features/interventions/ui/forms/intervention-work-item-form/intervention-work-item-form.component.html - 168,170 + 179,181 Hours src/app/features/organization/features/interventions/ui/forms/intervention-work-item-form/intervention-work-item-form.component.html - 173,175 + 184,186 Minutes src/app/features/organization/features/interventions/ui/forms/intervention-work-item-form/intervention-work-item-form.component.html - 200,202 + 211,213 Optional. Leave both fields empty if not estimated yet. src/app/features/organization/features/interventions/ui/forms/intervention-work-item-form/intervention-work-item-form.component.html - 233,236 + 244,247 Work period src/app/features/organization/features/interventions/ui/forms/intervention-work-item-form/intervention-work-item-form.component.html - 238,240 + 249,251 src/app/features/organization/features/interventions/ui/forms/intervention-work-item-form/intervention-work-item-form.component.html - 279,280 + 290,291 Choose specific dates src/app/features/organization/features/interventions/ui/forms/intervention-work-item-form/intervention-work-item-form.component.html - 272,274 + 283,285 src/app/features/organization/features/interventions/ui/forms/intervention-work-item-form/intervention-work-item-form.component.html - 334,336 + 345,347 Choose the first and last day of work. src/app/features/organization/features/interventions/ui/forms/intervention-work-item-form/intervention-work-item-form.component.html - 281,283 + 292,294 Apply src/app/features/organization/features/interventions/ui/forms/intervention-work-item-form/intervention-work-item-form.component.html - 319,320 + 330,331 src/app/shared/collection-filters/ui/components/collection-filter-date-range/collection-filter-date-range.component.html @@ -21176,70 +23174,91 @@ By default: . src/app/features/organization/features/interventions/ui/forms/intervention-work-item-form/intervention-work-item-form.component.html - 340,341 + 351,352 Optional. Uses the intervention dates once scheduled. src/app/features/organization/features/interventions/ui/forms/intervention-work-item-form/intervention-work-item-form.component.html - 343,347 + 354,358 Use intervention dates src/app/features/organization/features/interventions/ui/forms/intervention-work-item-form/intervention-work-item-form.component.html - 358,361 + 369,372 Adding… src/app/features/organization/features/interventions/ui/forms/intervention-work-item-form/intervention-work-item-form.component.html - 390,392 + 401,403 Add to prepared scope src/app/features/organization/features/interventions/ui/forms/intervention-work-item-form/intervention-work-item-form.component.html - 392,395 + 403,406 + + + + Select equipment + + src/app/features/organization/features/interventions/ui/forms/intervention-work-item-form/intervention-work-item-form.component.ts + 542 + + + + Optional site, facility or equipment target + + src/app/features/organization/features/interventions/ui/forms/intervention-work-item-form/intervention-work-item-form.component.ts + 543 Enter a valid number of whole hours, or leave empty. src/app/features/organization/features/interventions/ui/forms/intervention-work-item-form/intervention-work-item-form.component.ts - 635 + 766 Enter whole minutes between 0 and 59. src/app/features/organization/features/interventions/ui/forms/intervention-work-item-form/intervention-work-item-form.component.ts - 643 + 774 Choose a start and end date within the intervention period. src/app/features/organization/features/interventions/ui/forms/intervention-work-item-form/intervention-work-item-form.component.ts - 655 + 786 Choose what kind of work this item records. src/app/features/organization/features/interventions/ui/forms/intervention-work-item-form/intervention-work-item-form.component.ts - 659 + 790 + + + + Choose the equipment this work concerns. + + src/app/features/organization/features/interventions/ui/forms/intervention-work-item-form/intervention-work-item-form.component.ts + 798 Unknown target src/app/features/organization/features/interventions/ui/forms/intervention-work-item-form/intervention-work-item-form.component.ts - 674 + 818 @@ -21406,123 +23425,207 @@ src/app/features/organization/features/interventions/ui/pages/intervention-detail-page/intervention-detail-page.component.html - 1392,1393 + 1590,1591 + + + + Parts used + + src/app/features/organization/features/interventions/ui/pages/intervention-detail-page/intervention-detail-page.component.html + 438,439 + + + + Hide declarations + + src/app/features/organization/features/interventions/ui/pages/intervention-detail-page/intervention-detail-page.component.html + 452,454 + + + + View and declare parts + + src/app/features/organization/features/interventions/ui/pages/intervention-detail-page/intervention-detail-page.component.html + 454,456 + + + + Declaration not yet saved on this device + + src/app/features/organization/features/interventions/ui/pages/intervention-detail-page/intervention-detail-page.component.html + 467,468 + + + + Keep this page open. Retry the same declaration before leaving or publishing. + + src/app/features/organization/features/interventions/ui/pages/intervention-detail-page/intervention-detail-page.component.html + 473,475 + + + + Stock preparation could not finish + + src/app/features/organization/features/interventions/ui/pages/intervention-detail-page/intervention-detail-page.component.html + 481,482 + + + + Retry preparation + + src/app/features/organization/features/interventions/ui/pages/intervention-detail-page/intervention-detail-page.component.html + 493,494 + + + + Connect and prepare this intervention’s parts and warehouses before declaring them offline. + + src/app/features/organization/features/interventions/ui/pages/intervention-detail-page/intervention-detail-page.component.html + 505,507 + + + + References saved on this device. Stock availability is confirmed when declarations reach the server. + + src/app/features/organization/features/interventions/ui/pages/intervention-detail-page/intervention-detail-page.component.html + 514,515 + + + + Preparing stock references and declarations… + + src/app/features/organization/features/interventions/ui/pages/intervention-detail-page/intervention-detail-page.component.html + 524,525 + + + + A declaration added after publication updates current stock records. The published dossier keeps its original data. + + src/app/features/organization/features/interventions/ui/pages/intervention-detail-page/intervention-detail-page.component.html + 544,545 + + + + View internal maintenance costs + + src/app/features/organization/features/interventions/ui/pages/intervention-detail-page/intervention-detail-page.component.html + 578,582 Add facility src/app/features/organization/features/interventions/ui/pages/intervention-detail-page/intervention-detail-page.component.html - 516,518 + 672,674 Properties src/app/features/organization/features/interventions/ui/pages/intervention-detail-page/intervention-detail-page.component.html - 617,619 + 774,776 Planning, assignment and intervention status. src/app/features/organization/features/interventions/ui/pages/intervention-detail-page/intervention-detail-page.component.html - 623,625 + 780,782 Previous intervention src/app/features/organization/features/interventions/ui/pages/intervention-detail-page/intervention-detail-page.component.html - 681,682 + 838,839 src/app/features/organization/features/interventions/ui/pages/intervention-detail-page/intervention-detail-page.component.html - 686,687 + 843,844 Next intervention src/app/features/organization/features/interventions/ui/pages/intervention-detail-page/intervention-detail-page.component.html - 698,699 + 855,856 src/app/features/organization/features/interventions/ui/pages/intervention-detail-page/intervention-detail-page.component.html - 703,704 + 860,861 This intervention could not be opened src/app/features/organization/features/interventions/ui/pages/intervention-detail-page/intervention-detail-page.component.html - 715,717 + 872,874 Back to interventions src/app/features/organization/features/interventions/ui/pages/intervention-detail-page/intervention-detail-page.component.html - 737,740 + 894,897 src/app/features/organization/features/interventions/ui/pages/intervention-detail-page/intervention-detail-page.component.html - 758,761 + 915,918 Intervention not found src/app/features/organization/features/interventions/ui/pages/intervention-detail-page/intervention-detail-page.component.html - 745,746 + 902,903 This intervention does not exist, or you no longer have access to it. src/app/features/organization/features/interventions/ui/pages/intervention-detail-page/intervention-detail-page.component.html - 747,750 + 904,907 Intervention actions src/app/features/organization/features/interventions/ui/pages/intervention-detail-page/intervention-detail-page.component.html - 944,945 + 1111,1112 src/app/features/organization/features/interventions/ui/pages/intervention-detail-page/intervention-detail-page.component.html - 974,975 + 1147,1148 Intervention activity src/app/features/organization/features/interventions/ui/pages/intervention-detail-page/intervention-detail-page.component.html - 1007,1009 + 1180,1182 src/app/features/organization/features/interventions/ui/pages/intervention-detail-page/intervention-detail-page.component.html - 1051,1057 + 1224,1230 src/app/features/organization/features/interventions/ui/pages/intervention-detail-page/intervention-detail-page.component.html - 1250,1252 + 1427,1429 More actions src/app/features/organization/features/interventions/ui/pages/intervention-detail-page/intervention-detail-page.component.html - 1027,1030 + 1200,1203 src/app/features/organization/features/interventions/ui/pages/intervention-detail-page/intervention-detail-page.component.html - 1036,1037 + 1209,1210 src/app/features/organization/features/interventions/ui/pages/intervention-detail-page/intervention-detail-page.component.html - 1237,1240 + 1414,1417 @@ -21532,11 +23635,11 @@ />"/>View saved operations src/app/features/organization/features/interventions/ui/pages/intervention-detail-page/intervention-detail-page.component.html - 1065,1071 + 1238,1244 src/app/features/organization/features/interventions/ui/pages/intervention-detail-page/intervention-detail-page.component.html - 1259,1261 + 1436,1438 @@ -21546,11 +23649,11 @@ />"/> blocking issues src/app/features/organization/features/interventions/ui/pages/intervention-detail-page/intervention-detail-page.component.html - 1081,1087 + 1254,1260 src/app/features/organization/features/interventions/ui/pages/intervention-detail-page/intervention-detail-page.component.html - 1270,1272 + 1447,1449 @@ -21560,259 +23663,277 @@ />"/>Team chat src/app/features/organization/features/interventions/ui/pages/intervention-detail-page/intervention-detail-page.component.html - 1100,1106 + 1273,1279 src/app/features/organization/features/interventions/ui/pages/intervention-detail-page/intervention-detail-page.component.html - 1284,1286 + 1461,1463 Send it to src/app/features/organization/features/interventions/ui/pages/intervention-detail-page/intervention-detail-page.component.html - 1115,1116 + 1288,1289 src/app/features/organization/features/interventions/ui/pages/intervention-detail-page/intervention-detail-page.component.html - 1294,1296 + 1471,1473 Assign team… src/app/features/organization/features/interventions/ui/pages/intervention-detail-page/intervention-detail-page.component.html - 1147,1149 + 1320,1322 src/app/features/organization/features/interventions/ui/pages/intervention-detail-page/intervention-detail-page.component.html - 1315,1318 + 1492,1495 Duplicate src/app/features/organization/features/interventions/ui/pages/intervention-detail-page/intervention-detail-page.component.html - 1164,1166 + 1337,1339 src/app/features/organization/features/interventions/ui/pages/intervention-detail-page/intervention-detail-page.component.html - 1325,1328 + 1502,1505 src/app/features/organization/features/interventions/ui/tables/intervention-table/intervention-table.component.html 536,540 - - Export report + + Export validated report src/app/features/organization/features/interventions/ui/pages/intervention-detail-page/intervention-detail-page.component.html - 1182,1184 + 1356,1358 + + + src/app/features/organization/features/interventions/ui/pages/intervention-detail-page/intervention-detail-page.component.html + 1514,1516 + + + + Export draft report + + src/app/features/organization/features/interventions/ui/pages/intervention-detail-page/intervention-detail-page.component.html + 1358,1360 src/app/features/organization/features/interventions/ui/pages/intervention-detail-page/intervention-detail-page.component.html - 1336,1338 + 1516,1518 Abandon intervention src/app/features/organization/features/interventions/ui/pages/intervention-detail-page/intervention-detail-page.component.html - 1199,1201 + 1376,1378 src/app/features/organization/features/interventions/ui/pages/intervention-detail-page/intervention-detail-page.component.html - 1345,1348 + 1526,1529 Delete intervention src/app/features/organization/features/interventions/ui/pages/intervention-detail-page/intervention-detail-page.component.html - 1217,1219 + 1394,1396 src/app/features/organization/features/interventions/ui/pages/intervention-detail-page/intervention-detail-page.component.html - 1356,1358 + 1537,1539 View all intervention files src/app/features/organization/features/interventions/ui/pages/intervention-detail-page/intervention-detail-page.component.html - 1429,1431 + 1627,1629 Action required src/app/features/organization/features/interventions/ui/pages/intervention-detail-page/intervention-detail-page.component.ts - 985 + 1494 Synchronizing src/app/features/organization/features/interventions/ui/pages/intervention-detail-page/intervention-detail-page.component.ts - 987 + 1496 Saved on this device src/app/features/organization/features/interventions/ui/pages/intervention-detail-page/intervention-detail-page.component.ts - 989 + 1498 Checking saved work… src/app/features/organization/features/interventions/ui/pages/intervention-detail-page/intervention-detail-page.component.ts - 991 + 1500 Action failed src/app/features/organization/features/interventions/ui/pages/intervention-detail-page/intervention-detail-page.component.ts - 1336 + 1926 src/app/features/organization/features/interventions/ui/pages/intervention-detail-page/intervention-detail-page.component.ts - 1356 + 1946 Change saved src/app/features/organization/features/interventions/ui/pages/intervention-detail-page/intervention-detail-page.component.ts - 1341 + 1931 src/app/features/organization/features/interventions/ui/pages/intervention-detail-page/intervention-detail-page.component.ts - 5010 + 5747 Published to the compliance record src/app/features/organization/features/interventions/ui/pages/intervention-detail-page/intervention-detail-page.component.ts - 1654 + 2261 Overdue src/app/features/organization/features/interventions/ui/pages/intervention-detail-page/intervention-detail-page.component.ts - 3222 + 3846 Choose a site src/app/features/organization/features/interventions/ui/pages/intervention-detail-page/intervention-detail-page.component.ts - 3254 + 3878 Assign a responsible agent src/app/features/organization/features/interventions/ui/pages/intervention-detail-page/intervention-detail-page.component.ts - 3260 + 3884 Set a planned start src/app/features/organization/features/interventions/ui/pages/intervention-detail-page/intervention-detail-page.component.ts - 3266 + 3890 Set a due date src/app/features/organization/features/interventions/ui/pages/intervention-detail-page/intervention-detail-page.component.ts - 3272 + 3896 Prepare the field work src/app/features/organization/features/interventions/ui/pages/intervention-detail-page/intervention-detail-page.component.ts - 3278 + 3902 could not be prepared for upload. src/app/features/organization/features/interventions/ui/pages/intervention-detail-page/intervention-detail-page.component.ts - 3618 + 4349 src/app/features/organization/features/interventions/ui/pages/intervention-detail-page/intervention-detail-page.component.ts - 3685 + 4416 Couldn't download . src/app/features/organization/features/interventions/ui/pages/intervention-detail-page/intervention-detail-page.component.ts - 3771 + 4502 Couldn't export the intervention report. src/app/features/organization/features/interventions/ui/pages/intervention-detail-page/intervention-detail-page.component.ts - 3808 + 4539 No QR code could be read from this capture. src/app/features/organization/features/interventions/ui/pages/intervention-detail-page/intervention-detail-page.component.ts - 3858 + 4589 src/app/features/organization/features/interventions/ui/pages/intervention-detail-page/intervention-detail-page.component.ts - 3861 + 4592 src/app/features/organization/features/interventions/ui/pages/intervention-detail-page/intervention-detail-page.component.ts - 3891 + 4622 No work item of this intervention matches the scanned code. src/app/features/organization/features/interventions/ui/pages/intervention-detail-page/intervention-detail-page.component.ts - 3869 + 4600 src/app/features/organization/features/interventions/ui/pages/intervention-detail-page/intervention-detail-page.component.ts - 3872 + 4603 Found: src/app/features/organization/features/interventions/ui/pages/intervention-detail-page/intervention-detail-page.component.ts - 3885 + 4616 Couldn't load the organization's teams. src/app/features/organization/features/interventions/ui/pages/intervention-detail-page/intervention-detail-page.component.ts - 4414 + 5145 + + + + Save the submitted parts declaration on this device before publishing. Retry the declaration in Parts used. + + src/app/features/organization/features/interventions/ui/pages/intervention-detail-page/intervention-detail-page.component.ts + 5392 Connect to the network to publish. src/app/features/organization/features/interventions/ui/pages/intervention-detail-page/intervention-detail-page.component.ts - 4661 + 5398 src/app/features/organization/features/interventions/ui/pages/intervention-detail-page/utils/intervention-command-action/intervention-command-action.utils.ts @@ -21823,14 +23944,14 @@ Resolve this intervention's local operations before publishing. src/app/features/organization/features/interventions/ui/pages/intervention-detail-page/intervention-detail-page.component.ts - 4682 + 5419 Publication is unavailable. Review the updated permissions, connection and blocking issues. src/app/features/organization/features/interventions/ui/pages/intervention-detail-page/intervention-detail-page.component.ts - 4704 + 5441 @@ -22387,105 +24508,105 @@ Export needs the server; you're seeing this device's saved copy. src/app/features/organization/features/interventions/ui/pages/interventions-page/interventions-page.component.ts - 1735 + 1778 items in src/app/features/organization/features/interventions/ui/pages/interventions-page/interventions-page.component.ts - 2173 + 2216 Delete () src/app/features/organization/features/interventions/ui/pages/interventions-page/interventions-page.component.ts - 2370 + 2413 Assign responsible… () src/app/features/organization/features/interventions/ui/pages/interventions-page/interventions-page.component.ts - 2411 + 2454 Move to src/app/features/organization/features/interventions/ui/pages/interventions-page/interventions-page.component.ts - 2473 + 2516 No selected intervention can be deleted. src/app/features/organization/features/interventions/ui/pages/interventions-page/interventions-page.component.ts - 2500 + 2543 Assignment could not be saved. src/app/features/organization/features/interventions/ui/pages/interventions-page/interventions-page.component.ts - 2562 + 2605 Delete interventions? src/app/features/organization/features/interventions/ui/pages/interventions-page/interventions-page.component.ts - 2599 + 2642 Delete intervention? src/app/features/organization/features/interventions/ui/pages/interventions-page/interventions-page.component.ts - 2602 + 2645 This will permanently delete interventions. This action cannot be undone. src/app/features/organization/features/interventions/ui/pages/interventions-page/interventions-page.component.ts - 2623 + 2666 This will permanently delete this intervention. This action cannot be undone. src/app/features/organization/features/interventions/ui/pages/interventions-page/interventions-page.component.ts - 2624 + 2667 This will permanently delete "". This action cannot be undone. src/app/features/organization/features/interventions/ui/pages/interventions-page/interventions-page.component.ts - 2630 + 2673 Ascending src/app/features/organization/features/interventions/ui/pages/interventions-page/interventions-page.component.ts - 2837 + 2880 Descending src/app/features/organization/features/interventions/ui/pages/interventions-page/interventions-page.component.ts - 2838 + 2881 Change filter: src/app/features/organization/features/interventions/ui/pages/interventions-page/interventions-page.component.ts - 2855 + 2898 src/app/features/organization/features/interventions/ui/tables/intervention-change-table/intervention-change-table.component.ts @@ -22493,21 +24614,21 @@ src/app/features/organization/features/interventions/ui/tables/intervention-work-item-table/intervention-work-item-table.component.ts - 1020 + 1023 Selected day interventions src/app/features/organization/features/interventions/ui/pages/interventions-page/interventions-page.component.ts - 3383 + 3426 Status src/app/features/organization/features/interventions/ui/pages/interventions-page/interventions-page.component.ts - 3805 + 3866 src/app/features/organization/features/interventions/ui/tables/intervention-table/intervention-table.component.html @@ -22518,7 +24639,7 @@ Priority src/app/features/organization/features/interventions/ui/pages/interventions-page/interventions-page.component.ts - 3807 + 3868 src/app/features/organization/features/interventions/ui/tables/intervention-table/intervention-table.component.html @@ -22529,7 +24650,7 @@ Site src/app/features/organization/features/interventions/ui/pages/interventions-page/interventions-page.component.ts - 3811 + 3872 src/app/features/organization/features/interventions/ui/tables/intervention-table/intervention-table.component.html @@ -22540,7 +24661,7 @@ Responsible src/app/features/organization/features/interventions/ui/pages/interventions-page/interventions-page.component.ts - 3813 + 3874 src/app/features/organization/features/interventions/ui/tables/intervention-table/intervention-table.component.html @@ -22551,7 +24672,7 @@ Participants src/app/features/organization/features/interventions/ui/pages/interventions-page/interventions-page.component.ts - 3815 + 3876 src/app/features/organization/features/interventions/ui/tables/intervention-table/intervention-table.component.html @@ -22562,7 +24683,7 @@ Start src/app/features/organization/features/interventions/ui/pages/interventions-page/interventions-page.component.ts - 3817 + 3878 src/app/features/organization/features/interventions/ui/tables/intervention-table/intervention-table.component.html @@ -22573,7 +24694,7 @@ Updated src/app/features/organization/features/interventions/ui/pages/interventions-page/interventions-page.component.ts - 3819 + 3880 src/app/features/organization/features/interventions/ui/tables/intervention-table/intervention-table.component.html @@ -22584,7 +24705,7 @@ Due src/app/features/organization/features/interventions/ui/pages/interventions-page/interventions-page.component.ts - 3821 + 3882 src/app/features/organization/features/interventions/ui/tables/intervention-table/intervention-table.component.html @@ -22595,35 +24716,35 @@ 1 intervention src/app/features/organization/features/interventions/ui/pages/interventions-page/interventions-page.component.ts - 4427 + 4497 interventions src/app/features/organization/features/interventions/ui/pages/interventions-page/interventions-page.component.ts - 4428 + 4498 Some active filters aren't supported by the export and were left out. src/app/features/organization/features/interventions/ui/pages/interventions-page/interventions-page.component.ts - 4779 + 4849 Couldn't export interventions. src/app/features/organization/features/interventions/ui/pages/interventions-page/interventions-page.component.ts - 4797 + 4867 Member src/app/features/organization/features/interventions/ui/pages/interventions-page/interventions-page.component.ts - 5915 + 5985 @@ -22739,11 +24860,11 @@ src/app/features/organization/features/interventions/ui/tables/intervention-work-item-table/intervention-work-item-table.component.html - 406,408 + 410,412 src/app/features/organization/features/interventions/ui/tables/intervention-work-item-table/intervention-work-item-table.component.html - 621,623 + 629,631 @@ -22754,11 +24875,11 @@ src/app/features/organization/features/interventions/ui/tables/intervention-work-item-table/intervention-work-item-table.component.html - 416,417 + 420,421 src/app/features/organization/features/interventions/ui/tables/intervention-work-item-table/intervention-work-item-table.component.html - 631,632 + 639,640 @@ -22821,94 +24942,116 @@ The resource changed on the server. Review your local content before reapplying it. src/app/features/organization/features/interventions/ui/sheets/intervention-operations-sheet/intervention-operations-sheet.component.html - 54,56 + 64,66 Original local revision: src/app/features/organization/features/interventions/ui/sheets/intervention-operations-sheet/intervention-operations-sheet.component.html - 58,59 + 68,69 Last confirmed server revision: src/app/features/organization/features/interventions/ui/sheets/intervention-operations-sheet/intervention-operations-sheet.component.html - 63,64 + 73,74 The server revision could not be verified. Your local content is preserved. src/app/features/organization/features/interventions/ui/sheets/intervention-operations-sheet/intervention-operations-sheet.component.html - 67,69 + 77,79 Saved locally · awaiting synchronization src/app/features/organization/features/interventions/ui/sheets/intervention-operations-sheet/intervention-operations-sheet.component.html - 72,74 + 82,84 Discard local operation src/app/features/organization/features/interventions/ui/sheets/intervention-operations-sheet/intervention-operations-sheet.component.html - 104,106 + 114,116 Discard this local operation? src/app/features/organization/features/interventions/ui/sheets/intervention-operations-sheet/intervention-operations-sheet.component.html - 128,130 + 138,140 Reapply your local content? src/app/features/organization/features/interventions/ui/sheets/intervention-operations-sheet/intervention-operations-sheet.component.html - 130,133 + 140,143 This saved operation will be removed from this device without being sent. This cannot be undone. src/app/features/organization/features/interventions/ui/sheets/intervention-operations-sheet/intervention-operations-sheet.component.html - 143,146 + 163,166 Current server values src/app/features/organization/features/interventions/ui/sheets/intervention-operations-sheet/intervention-operations-sheet.component.html - 149,151 + 169,171 src/app/features/organization/features/interventions/ui/sheets/intervention-time-sheet/intervention-time-sheet.component.html - 304,307 + 438,441 Your stored values will be sent again using the available revision. The server can reject them if the resource has changed. No automatic merge will be performed. src/app/features/organization/features/interventions/ui/sheets/intervention-operations-sheet/intervention-operations-sheet.component.html - 156,160 + 176,180 Confirm src/app/features/organization/features/interventions/ui/sheets/intervention-operations-sheet/intervention-operations-sheet.component.html - 170,172 + 190,192 src/app/features/organization/features/webhooks/ui/pages/webhooks-page/webhooks-page.component.html 542,544 + + Performed + + src/app/features/organization/features/interventions/ui/sheets/intervention-operations-sheet/intervention-operations-sheet.component.html + 198,200 + + + src/app/features/organization/features/interventions/ui/tables/intervention-work-item-table/intervention-work-item-table.component.html + 764,766 + + + src/app/features/organization/features/interventions/ui/tables/intervention-work-item-table/intervention-work-item-table.component.html + 793,795 + + + + Replacement equipment: + + src/app/features/organization/features/interventions/ui/sheets/intervention-operations-sheet/intervention-operations-sheet.component.html + 204,207 + + Edit recurrence @@ -22938,11 +25081,11 @@ src/app/features/organization/features/interventions/ui/tables/intervention-work-item-table/intervention-work-item-table.component.html - 426,427 + 430,431 src/app/features/organization/features/interventions/ui/tables/intervention-work-item-table/intervention-work-item-table.component.html - 641,642 + 649,650 @@ -22966,158 +25109,214 @@ 30,32 + + Checking saved correction + + src/app/features/organization/features/interventions/ui/sheets/intervention-time-sheet/intervention-time-sheet.component.html + 53,54 + + Refresh journal src/app/features/organization/features/interventions/ui/sheets/intervention-time-sheet/intervention-time-sheet.component.html - 69,71 + 92,94 Discard draft src/app/features/organization/features/interventions/ui/sheets/intervention-time-sheet/intervention-time-sheet.component.html - 80,83 + 103,106 src/app/features/organization/features/interventions/ui/sheets/intervention-time-sheet/intervention-time-sheet.component.html - 273,274 + 407,408 src/app/features/organization/features/interventions/ui/sheets/intervention-time-sheet/intervention-time-sheet.component.html - 285,286 + 419,420 This entry changed since your correction began. Compare the current server values before saving. src/app/features/organization/features/interventions/ui/sheets/intervention-time-sheet/intervention-time-sheet.component.html - 86,88 + 109,111 Review correction src/app/features/organization/features/interventions/ui/sheets/intervention-time-sheet/intervention-time-sheet.component.html - 95,98 + 118,121 src/app/features/organization/features/interventions/ui/sheets/intervention-time-sheet/intervention-time-sheet.component.html - 296,297 + 430,431 Resume draft src/app/features/organization/features/interventions/ui/sheets/intervention-time-sheet/intervention-time-sheet.component.html - 112,114 + 135,137 Log time src/app/features/organization/features/interventions/ui/sheets/intervention-time-sheet/intervention-time-sheet.component.html - 114,116 + 137,139 No journal has been saved on this device. Reconnect to load its history. src/app/features/organization/features/interventions/ui/sheets/intervention-time-sheet/intervention-time-sheet.component.html - 121,123 + 145,147 + + + + This journal page has not been saved on this device. Reconnect to load it. + + src/app/features/organization/features/interventions/ui/sheets/intervention-time-sheet/intervention-time-sheet.component.html + 149,151 Cancelled src/app/features/organization/features/interventions/ui/sheets/intervention-time-sheet/intervention-time-sheet.component.html - 139,142 + 169,172 src/app/features/organization/features/interventions/ui/sheets/intervention-time-sheet/intervention-time-sheet.component.html - 175,177 + 230,232 Awaiting synchronization src/app/features/organization/features/interventions/ui/sheets/intervention-time-sheet/intervention-time-sheet.component.html - 144,146 + 174,176 This local change needs review in pending operations. It has not overwritten server history. src/app/features/organization/features/interventions/ui/sheets/intervention-time-sheet/intervention-time-sheet.component.html - 150,152 + 180,182 Correction history src/app/features/organization/features/interventions/ui/sheets/intervention-time-sheet/intervention-time-sheet.component.html - 163,164 + 200,201 + + + + Synchronize or review this pending change before loading correction history. + + src/app/features/organization/features/interventions/ui/sheets/intervention-time-sheet/intervention-time-sheet.component.html + 215,217 + + + + Loading correction history + + src/app/features/organization/features/interventions/ui/sheets/intervention-time-sheet/intervention-time-sheet.component.html + 243,244 + + + + Load earlier versions + + src/app/features/organization/features/interventions/ui/sheets/intervention-time-sheet/intervention-time-sheet.component.html + 272,273 Correct src/app/features/organization/features/interventions/ui/sheets/intervention-time-sheet/intervention-time-sheet.component.html - 195,196 + 287,288 Cancel entry src/app/features/organization/features/interventions/ui/sheets/intervention-time-sheet/intervention-time-sheet.component.html - 205,206 + 297,298 src/app/features/organization/features/interventions/ui/sheets/intervention-time-sheet/intervention-time-sheet.component.html - 262,263 + 396,397 No time logged yet src/app/features/organization/features/interventions/ui/sheets/intervention-time-sheet/intervention-time-sheet.component.html - 215,216 + 307,308 Log actual work without changing the remaining estimate. src/app/features/organization/features/interventions/ui/sheets/intervention-time-sheet/intervention-time-sheet.component.html - 217,219 + 309,311 + + + + Time journal pages + + src/app/features/organization/features/interventions/ui/sheets/intervention-time-sheet/intervention-time-sheet.component.html + 318,319 + + + + Page + + src/app/features/organization/features/interventions/ui/sheets/intervention-time-sheet/intervention-time-sheet.component.html + 322,323 + + + + Saved entries: + + src/app/features/organization/features/interventions/ui/sheets/intervention-time-sheet/intervention-time-sheet.component.html + 326,327 Cancel this time entry? src/app/features/organization/features/interventions/ui/sheets/intervention-time-sheet/intervention-time-sheet.component.html - 250,251 + 384,385 It will no longer count as time spent. Its full correction history will be retained. src/app/features/organization/features/interventions/ui/sheets/intervention-time-sheet/intervention-time-sheet.component.html - 252,255 + 386,389 Back src/app/features/organization/features/interventions/ui/sheets/intervention-time-sheet/intervention-time-sheet.component.html - 256,258 + 390,392 src/app/features/organization/features/interventions/ui/sheets/intervention-time-sheet/intervention-time-sheet.component.html - 279,281 + 413,415 src/app/features/organization/features/interventions/ui/sheets/intervention-time-sheet/intervention-time-sheet.component.html - 317,319 + 451,453 src/app/features/organization/features/workload/ui/dialogs/workload-confirmation-dialog/workload-confirmation-dialog.component.html @@ -23136,28 +25335,28 @@ Only the unfinished input will be removed. Saved and queued time entries are not affected. src/app/features/organization/features/interventions/ui/sheets/intervention-time-sheet/intervention-time-sheet.component.html - 275,278 + 409,412 Continue editing your draft against the displayed server revision. Nothing is saved until you submit the form. src/app/features/organization/features/interventions/ui/sheets/intervention-time-sheet/intervention-time-sheet.component.html - 298,300 + 432,434 Your draft src/app/features/organization/features/interventions/ui/sheets/intervention-time-sheet/intervention-time-sheet.component.html - 311,312 + 445,446 Continue correction src/app/features/organization/features/interventions/ui/sheets/intervention-time-sheet/intervention-time-sheet.component.html - 323,325 + 457,459 @@ -23168,10 +25367,10 @@ - Only the action is required; target and assignee can be set now or during execution. + Choose the action and, for maintenance, the equipment concerned. Assignment and estimates are optional. src/app/features/organization/features/interventions/ui/sheets/intervention-work-item-sheet/intervention-work-item-sheet.component.html - 16,18 + 16,19 @@ -23334,7 +25533,7 @@ src/app/features/organization/features/interventions/ui/tables/intervention-work-item-table/intervention-work-item-table.component.ts - 1002 + 1005 @@ -23391,7 +25590,7 @@ src/app/features/organization/features/interventions/ui/tables/intervention-equipment-table/intervention-equipment-table.component.ts - 373 + 430 @@ -23423,7 +25622,7 @@ src/app/features/organization/features/interventions/ui/tables/intervention-equipment-table/intervention-equipment-table.component.ts - 379 + 436 @@ -23549,7 +25748,7 @@ () src/app/features/organization/features/interventions/ui/tables/intervention-equipment-table/intervention-equipment-table.component.ts - 836 + 930 @@ -24292,7 +26491,7 @@ src/app/features/organization/features/interventions/ui/tables/intervention-work-item-table/intervention-work-item-table.component.html - 504,506 + 508,510 @@ -24303,206 +26502,227 @@ src/app/features/organization/features/interventions/ui/tables/intervention-work-item-table/intervention-work-item-table.component.html - 520,522 + 524,526 Saved on this device src/app/features/organization/features/interventions/ui/tables/intervention-work-item-table/intervention-work-item-table.component.html - 334,335 + 338,339 src/app/features/organization/features/interventions/ui/tables/intervention-work-item-table/intervention-work-item-table.component.html - 338,340 + 342,344 src/app/features/organization/features/interventions/ui/tables/intervention-work-item-table/intervention-work-item-table.component.html - 515,517 + 519,521 Work item actions src/app/features/organization/features/interventions/ui/tables/intervention-work-item-table/intervention-work-item-table.component.html - 392,394 + 396,398 src/app/features/organization/features/interventions/ui/tables/intervention-work-item-table/intervention-work-item-table.component.html - 608,610 + 616,618 proof(s) src/app/features/organization/features/interventions/ui/tables/intervention-work-item-table/intervention-work-item-table.component.html - 438,439 + 442,443 src/app/features/organization/features/interventions/ui/tables/intervention-work-item-table/intervention-work-item-table.component.html - 653,654 + 661,662 No work items match this filter src/app/features/organization/features/interventions/ui/tables/intervention-work-item-table/intervention-work-item-table.component.html - 695,697 + 703,705 Choose another status, or show all work items. src/app/features/organization/features/interventions/ui/tables/intervention-work-item-table/intervention-work-item-table.component.html - 698,701 + 706,709 No work items yet src/app/features/organization/features/interventions/ui/tables/intervention-work-item-table/intervention-work-item-table.component.html - 724,725 + 732,733 New item src/app/features/organization/features/interventions/ui/tables/intervention-work-item-table/intervention-work-item-table.component.html - 739,741 + 747,749 + + + + Validated + + src/app/features/organization/features/interventions/ui/tables/intervention-work-item-table/intervention-work-item-table.component.html + 768,770 + + + + Pending review + + src/app/features/organization/features/interventions/ui/tables/intervention-work-item-table/intervention-work-item-table.component.html + 770,773 + + + + Previous attempts + + src/app/features/organization/features/interventions/ui/tables/intervention-work-item-table/intervention-work-item-table.component.html + 786,788 Not estimated src/app/features/organization/features/interventions/ui/tables/intervention-work-item-table/intervention-work-item-table.component.html - 755,757 + 815,817 src/app/features/organization/features/interventions/ui/tables/intervention-work-item-table/intervention-work-item-table.component.html - 800,803 + 860,863 Planned src/app/features/organization/features/interventions/ui/tables/intervention-work-item-table/intervention-work-item-table.component.html - 760,761 + 820,821 Estimated src/app/features/organization/features/interventions/ui/tables/intervention-work-item-table/intervention-work-item-table.component.html - 775,776 + 835,836 Spent src/app/features/organization/features/interventions/ui/tables/intervention-work-item-table/intervention-work-item-table.component.html - 783,784 + 843,844 Remaining src/app/features/organization/features/interventions/ui/tables/intervention-work-item-table/intervention-work-item-table.component.html - 791,792 + 851,852 src/app/features/organization/features/interventions/ui/tables/intervention-work-item-table/intervention-work-item-table.component.html - 798,799 + 858,859 Remaining src/app/features/organization/features/interventions/ui/tables/intervention-work-item-table/intervention-work-item-table.component.ts - 1037 + 1040 Done src/app/features/organization/features/interventions/ui/tables/intervention-work-item-table/intervention-work-item-table.component.ts - 1041 + 1044 Skipped src/app/features/organization/features/interventions/ui/tables/intervention-work-item-table/intervention-work-item-table.component.ts - 1045 + 1048 of items completed src/app/features/organization/features/interventions/ui/tables/intervention-work-item-table/intervention-work-item-table.component.ts - 1319 + 1322 List the tasks to complete during this intervention. src/app/features/organization/features/interventions/ui/tables/intervention-work-item-table/intervention-work-item-table.component.ts - 1354 + 1357 A planner has not listed the field work yet. src/app/features/organization/features/interventions/ui/tables/intervention-work-item-table/intervention-work-item-table.component.ts - 1355 + 1358 Discovered src/app/features/organization/features/interventions/ui/tables/intervention-work-item-table/intervention-work-item-table.component.ts - 1464 + 1467 Planned src/app/features/organization/features/interventions/ui/tables/intervention-work-item-table/intervention-work-item-table.component.ts - 1465 + 1468 Attach evidence — file(s) attached src/app/features/organization/features/interventions/ui/tables/intervention-work-item-table/intervention-work-item-table.component.ts - 1613 + 1646 Attach evidence src/app/features/organization/features/interventions/ui/tables/intervention-work-item-table/intervention-work-item-table.component.ts - 1614 + 1647 Skipped: src/app/features/organization/features/interventions/ui/tables/intervention-work-item-table/intervention-work-item-table.component.ts - 1658 + 1691 Reopen src/app/features/organization/features/interventions/ui/tables/intervention-work-item-table/intervention-work-item-table.component.ts - 1662 + 1695 Complete src/app/features/organization/features/interventions/ui/tables/intervention-work-item-table/intervention-work-item-table.component.ts - 1663 + 1696 @@ -24645,599 +26865,6580 @@ 59 - - Maintenance + + Inventory - src/app/features/organization/features/maintenance-schedules/maintenance-schedules.routes.ts - 43 + src/app/features/organization/features/inventory/inventory.routes.ts + 17 + + + Stock - src/app/features/organization/navigation/organization-navigation.config.ts - 455 + src/app/features/organization/features/inventory/inventory.routes.ts + 30 + + + src/app/features/organization/features/inventory/ui/pages/inventory-page/inventory-page.component.ts + 68 - - Every 4 weeks + + Parts and consumables - src/app/features/organization/features/maintenance-schedules/options/maintenance-override-duration-options.constants.ts - 24 + src/app/features/organization/features/inventory/inventory.routes.ts + 32 + + + src/app/features/organization/features/inventory/ui/pages/inventory-page/inventory-page.component.ts + 69 - - Every month + + Warehouses - src/app/features/organization/features/maintenance-schedules/options/maintenance-override-duration-options.constants.ts - 25 + src/app/features/organization/features/inventory/inventory.routes.ts + 34 + + + src/app/features/organization/features/inventory/ui/pages/inventory-page/inventory-page.component.ts + 70 - - Every 3 months + + Consumption declarations - src/app/features/organization/features/maintenance-schedules/options/maintenance-override-duration-options.constants.ts - 26 + src/app/features/organization/features/inventory/inventory.routes.ts + 36 + + + src/app/features/organization/features/inventory/ui/pages/inventory-page/inventory-page.component.ts + 71 - - Every 6 months + + Movement history - src/app/features/organization/features/maintenance-schedules/options/maintenance-override-duration-options.constants.ts - 27 + src/app/features/organization/features/inventory/inventory.routes.ts + 37 + + + src/app/features/organization/features/inventory/ui/pages/inventory-page/inventory-page.component.ts + 72 - - Every year + + The declaration is retained here until it is safely queued. Retry keeps the same recorded facts. - src/app/features/organization/features/maintenance-schedules/options/maintenance-override-duration-options.constants.ts - 28 + src/app/features/organization/features/inventory/ui/components/inventory-consumption-panel/inventory-consumption-panel.component.html + 26,30 - - Every 2 years + + Consumption history - src/app/features/organization/features/maintenance-schedules/options/maintenance-override-duration-options.constants.ts - 29 + src/app/features/organization/features/inventory/ui/components/inventory-consumption-panel/inventory-consumption-panel.component.html + 46,48 - - Every 5 years + + These states describe stock recording. They do not indicate completed work or a validated dossier. - src/app/features/organization/features/maintenance-schedules/options/maintenance-override-duration-options.constants.ts - 30 + src/app/features/organization/features/inventory/ui/components/inventory-consumption-panel/inventory-consumption-panel.component.html + 49,53 - - Every 10 years + + No consumption has been declared for this work. - src/app/features/organization/features/maintenance-schedules/options/maintenance-override-duration-options.constants.ts - 31 + src/app/features/organization/features/inventory/ui/components/inventory-consumption-panel/inventory-consumption-panel.component.html + 55,59 - - Campaign # created with work item(s) + + Recorded after publication; the published dossier remains unchanged. - src/app/features/organization/features/maintenance-schedules/state/maintenance-schedules/maintenance-schedules.store.ts - 269 + src/app/features/organization/features/inventory/ui/components/inventory-consumption-panel/inventory-consumption-panel.component.html + 104,106 - - Generate inspection campaign + + Stock debit confirmed - src/app/features/organization/features/maintenance-schedules/ui/dialogs/maintenance-campaign-dialog/maintenance-campaign-dialog.component.html - 14,15 + src/app/features/organization/features/inventory/ui/components/inventory-consumption-panel/inventory-consumption-panel.component.ts + 421 - - Creates an intervention covering every schedule currently due, optionally narrowed to one facility or equipment type. + + Received — reconciliation needed - src/app/features/organization/features/maintenance-schedules/ui/dialogs/maintenance-campaign-dialog/maintenance-campaign-dialog.component.html - 16,21 + src/app/features/organization/features/inventory/ui/components/inventory-consumption-panel/inventory-consumption-panel.component.ts + 422 - - Interval override + + No stock balance is recorded for this source. - src/app/features/organization/features/maintenance-schedules/ui/dialogs/maintenance-override-dialog/maintenance-override-dialog.component.html - 12,13 + src/app/features/organization/features/inventory/ui/components/inventory-consumption-panel/inventory-consumption-panel.component.ts + 425 - - Override how often this equipment type is inspected, or follow the organization default. + + The part or warehouse is archived. - src/app/features/organization/features/maintenance-schedules/ui/dialogs/maintenance-override-dialog/maintenance-override-dialog.component.html - 14,18 + src/app/features/organization/features/inventory/ui/components/inventory-consumption-panel/inventory-consumption-panel.component.ts + 427 - - Interval + + Recorded stock is insufficient for the full quantity. - src/app/features/organization/features/maintenance-schedules/ui/dialogs/maintenance-override-dialog/maintenance-override-dialog.component.html - 25,28 + src/app/features/organization/features/inventory/ui/components/inventory-consumption-panel/inventory-consumption-panel.component.ts + 429 - - Use organization default + + Sending — stock not confirmed - src/app/features/organization/features/maintenance-schedules/ui/dialogs/maintenance-override-dialog/maintenance-override-dialog.component.html - 45,47 + src/app/features/organization/features/inventory/ui/components/inventory-consumption-panel/inventory-consumption-panel.component.ts + 472 + + + Queued locally — retry needed - src/app/features/organization/features/maintenance-schedules/ui/dialogs/maintenance-override-dialog/maintenance-override-dialog.component.ts - 169 + src/app/features/organization/features/inventory/ui/components/inventory-consumption-panel/inventory-consumption-panel.component.ts + 474 - - Saving… + + Queued locally — awaiting synchronization - src/app/features/organization/features/maintenance-schedules/ui/dialogs/maintenance-override-dialog/maintenance-override-dialog.component.html - 77,79 + src/app/features/organization/features/inventory/ui/components/inventory-consumption-panel/inventory-consumption-panel.component.ts + 475 - - Save + + No matching references - src/app/features/organization/features/maintenance-schedules/ui/dialogs/maintenance-override-dialog/maintenance-override-dialog.component.html - 79,82 + src/app/features/organization/features/inventory/ui/components/inventory-part-picker/inventory-part-picker.component.html + 26,29 + + + src/app/features/organization/features/inventory/ui/components/inventory-warehouse-picker/inventory-warehouse-picker.component.html + 26,29 + + + src/app/features/organization/features/inventory/ui/forms/inventory-consumption-form/inventory-consumption-form.component.html + 27,29 + + + src/app/features/organization/features/inventory/ui/forms/inventory-consumption-form/inventory-consumption-form.component.html + 60,62 - - Unknown value + + Loading choices - src/app/features/organization/features/maintenance-schedules/ui/dialogs/maintenance-override-dialog/maintenance-override-dialog.component.ts - 174 + src/app/features/organization/features/inventory/ui/components/inventory-part-picker/inventory-part-picker.component.html + 32,35 + + + src/app/features/organization/features/inventory/ui/components/inventory-warehouse-picker/inventory-warehouse-picker.component.html + 32,35 - - Name + + Choices could not be loaded - src/app/features/organization/features/maintenance-schedules/ui/forms/maintenance-campaign-form/maintenance-campaign-form.component.html - 11,13 + src/app/features/organization/features/inventory/ui/components/inventory-part-picker/inventory-part-picker.component.html + 74,75 + + + src/app/features/organization/features/inventory/ui/components/inventory-warehouse-picker/inventory-warehouse-picker.component.html + 74,75 - - Q1 fire extinguisher round + + Selected reference could not be loaded - src/app/features/organization/features/maintenance-schedules/ui/forms/maintenance-campaign-form/maintenance-campaign-form.component.html - 19,20 + src/app/features/organization/features/inventory/ui/components/inventory-part-picker/inventory-part-picker.component.html + 92,94 + + + src/app/features/organization/features/inventory/ui/components/inventory-warehouse-picker/inventory-warehouse-picker.component.html + 92,94 - - Due before + + Choose a part - src/app/features/organization/features/maintenance-schedules/ui/forms/maintenance-campaign-form/maintenance-campaign-form.component.html - 32,34 + src/app/features/organization/features/inventory/ui/components/inventory-part-picker/inventory-part-picker.component.ts + 138 + + + src/app/features/organization/features/inventory/ui/forms/inventory-consumption-form/inventory-consumption-form.component.html + 16,17 - - Facility + + (archived) - src/app/features/organization/features/maintenance-schedules/ui/forms/maintenance-campaign-form/maintenance-campaign-form.component.html - 56,59 + src/app/features/organization/features/inventory/ui/components/inventory-part-picker/inventory-part-picker.component.ts + 157 + + + src/app/features/organization/features/inventory/ui/components/inventory-warehouse-picker/inventory-warehouse-picker.component.ts + 157 - - Equipment type + + Choose a warehouse - src/app/features/organization/features/maintenance-schedules/ui/forms/maintenance-campaign-form/maintenance-campaign-form.component.html - 75,77 + src/app/features/organization/features/inventory/ui/components/inventory-warehouse-picker/inventory-warehouse-picker.component.ts + 138 + + + src/app/features/organization/features/inventory/ui/forms/inventory-consumption-form/inventory-consumption-form.component.html + 49,50 - - Every type + + Received after publication - src/app/features/organization/features/maintenance-schedules/ui/forms/maintenance-campaign-form/maintenance-campaign-form.component.html - 84,85 + src/app/features/organization/features/inventory/ui/dataviews/inventory-dataview/inventory-dataview.component.html + 24,26 + + + Quantity - src/app/features/organization/features/maintenance-schedules/ui/forms/maintenance-campaign-form/maintenance-campaign-form.component.html - 91,93 + src/app/features/organization/features/inventory/ui/dataviews/inventory-dataview/inventory-dataview.component.html + 31 - src/app/features/organization/features/maintenance-schedules/ui/forms/maintenance-campaign-form/maintenance-campaign-form.component.ts - 364 + src/app/features/organization/features/inventory/ui/forms/inventory-movement-form/inventory-movement-form.component.html + 39,42 - - Generating… + + Open intervention - src/app/features/organization/features/maintenance-schedules/ui/forms/maintenance-campaign-form/maintenance-campaign-form.component.html - 125,127 + src/app/features/organization/features/inventory/ui/dataviews/inventory-dataview/inventory-dataview.component.html + 51,54 - - Generate campaign + + Restore - src/app/features/organization/features/maintenance-schedules/ui/forms/maintenance-campaign-form/maintenance-campaign-form.component.html - 127,131 + src/app/features/organization/features/inventory/ui/dataviews/inventory-dataview/inventory-dataview.component.html + 81,83 - - Name is required. + + Archive - src/app/features/organization/features/maintenance-schedules/ui/forms/maintenance-campaign-form/maintenance-campaign-form.component.ts - 305 + src/app/features/organization/features/inventory/ui/dataviews/inventory-dataview/inventory-dataview.component.html + 83,85 - - Enter at least 2 characters. + + Reconcile stock - src/app/features/organization/features/maintenance-schedules/ui/forms/maintenance-campaign-form/maintenance-campaign-form.component.ts - 308 + src/app/features/organization/features/inventory/ui/dataviews/inventory-dataview/inventory-dataview.component.html + 98,100 - - This name is too long. + + Record return - src/app/features/organization/features/maintenance-schedules/ui/forms/maintenance-campaign-form/maintenance-campaign-form.component.ts + src/app/features/organization/features/inventory/ui/dataviews/inventory-dataview/inventory-dataview.component.html + 112,113 + + + src/app/features/organization/features/inventory/ui/pages/inventory-page/inventory-page.component.ts + 565 + + + + No stock records in this view + + src/app/features/organization/features/inventory/ui/dataviews/inventory-dataview/inventory-dataview.component.html + 122,123 + + + + Change your filters or add a reference. Archived references remain readable in the history. + + src/app/features/organization/features/inventory/ui/dataviews/inventory-dataview/inventory-dataview.component.html + 124,125 + + + + Stock pages + + src/app/features/organization/features/inventory/ui/dataviews/inventory-dataview/inventory-dataview.component.html + 132,133 + + + + Previous + + src/app/features/organization/features/inventory/ui/dataviews/inventory-dataview/inventory-dataview.component.html + 144,146 + + + + Page of + + src/app/features/organization/features/inventory/ui/dataviews/inventory-dataview/inventory-dataview.component.html + 147,149 + + + + Next + + src/app/features/organization/features/inventory/ui/dataviews/inventory-dataview/inventory-dataview.component.html + 158,161 + + + + Declare parts used + + src/app/features/organization/features/inventory/ui/forms/inventory-consumption-form/inventory-consumption-form.component.html + 3,4 + + + + Record the physical quantity used. Stock is confirmed after synchronization. + + src/app/features/organization/features/inventory/ui/forms/inventory-consumption-form/inventory-consumption-form.component.html + 5,8 + + + + Part + + src/app/features/organization/features/inventory/ui/forms/inventory-consumption-form/inventory-consumption-form.component.html + 10,12 + + + + Warehouse + + src/app/features/organization/features/inventory/ui/forms/inventory-consumption-form/inventory-consumption-form.component.html + 43,45 + + + + Quantity used + + src/app/features/organization/features/inventory/ui/forms/inventory-consumption-form/inventory-consumption-form.component.html + 76,79 + + + + Declaration could not be queued + + src/app/features/organization/features/inventory/ui/forms/inventory-consumption-form/inventory-consumption-form.component.html + 96,97 + + + + Declare consumption + + src/app/features/organization/features/inventory/ui/forms/inventory-consumption-form/inventory-consumption-form.component.html + 105,109 + + + + Choose an active part. + + src/app/features/organization/features/inventory/ui/forms/inventory-consumption-form/inventory-consumption-form.component.ts + 282 + + + src/app/features/organization/features/inventory/ui/forms/inventory-consumption-form/inventory-consumption-form.component.ts + 289 + + + + Choose an active warehouse. + + src/app/features/organization/features/inventory/ui/forms/inventory-consumption-form/inventory-consumption-form.component.ts + 293 + + + src/app/features/organization/features/inventory/ui/forms/inventory-consumption-form/inventory-consumption-form.component.ts + 300 + + + + Enter the quantity used. + + src/app/features/organization/features/inventory/ui/forms/inventory-consumption-form/inventory-consumption-form.component.ts + 304 + + + + Enter a positive decimal with up to 18 whole digits and 6 decimal places, using a dot. + + src/app/features/organization/features/inventory/ui/forms/inventory-consumption-form/inventory-consumption-form.component.ts 311 - - A due-before date is required. + + Movement details - src/app/features/organization/features/maintenance-schedules/ui/forms/maintenance-campaign-form/maintenance-campaign-form.component.ts - 314 + src/app/features/organization/features/inventory/ui/forms/inventory-movement-form/inventory-movement-form.component.html + 3,5 + + + + Part + + src/app/features/organization/features/inventory/ui/forms/inventory-movement-form/inventory-movement-form.component.html + 8,10 + + + src/app/features/organization/features/inventory/ui/forms/inventory-reference-form/inventory-reference-form.component.html + 59,60 + + + src/app/features/organization/features/inventory/ui/pages/inventory-page/inventory-page.component.html + 167,168 + + + src/app/features/organization/features/inventory/ui/pages/inventory-page/inventory-page.component.ts + 590 + + + + Warehouse + + src/app/features/organization/features/inventory/ui/forms/inventory-movement-form/inventory-movement-form.component.html + 23,25 + + + src/app/features/organization/features/inventory/ui/pages/inventory-page/inventory-page.component.html + 176,178 + + + + Use a negative quantity to remove stock, or a positive quantity to add it. + + src/app/features/organization/features/inventory/ui/forms/inventory-movement-form/inventory-movement-form.component.html + 50,52 + + + + Enter the quantity physically returned from this consumption. + + src/app/features/organization/features/inventory/ui/forms/inventory-movement-form/inventory-movement-form.component.html + 54,57 + + + + Reason + + src/app/features/organization/features/inventory/ui/forms/inventory-movement-form/inventory-movement-form.component.html + 65,67 + + + + The movement could not be confirmed + + src/app/features/organization/features/inventory/ui/forms/inventory-movement-form/inventory-movement-form.component.html + 78,79 + + + + Record movement + + src/app/features/organization/features/inventory/ui/forms/inventory-movement-form/inventory-movement-form.component.html + 98,102 + + + + Enter a non-zero decimal with up to 18 whole digits and 6 decimal places, using a dot. + + src/app/features/organization/features/inventory/ui/forms/inventory-movement-form/inventory-movement-form.component.ts + 254 + + + + Explain why this movement is needed. + + src/app/features/organization/features/inventory/ui/forms/inventory-movement-form/inventory-movement-form.component.ts + 262 + + + + Stock reference + + src/app/features/organization/features/inventory/ui/forms/inventory-reference-form/inventory-reference-form.component.html + 3,5 + + + + Permanent code + + src/app/features/organization/features/inventory/ui/forms/inventory-reference-form/inventory-reference-form.component.html + 7,9 + + + + Codes and part categories cannot be changed after creation. + + src/app/features/organization/features/inventory/ui/forms/inventory-reference-form/inventory-reference-form.component.html + 11,13 + + + + Name + + src/app/features/organization/features/inventory/ui/forms/inventory-reference-form/inventory-reference-form.component.html + 21,23 + + + + Stock unit + + src/app/features/organization/features/inventory/ui/forms/inventory-reference-form/inventory-reference-form.component.html + 33,35 + + + + For example: piece, metre or litre. Quantities use this unit. + + src/app/features/organization/features/inventory/ui/forms/inventory-reference-form/inventory-reference-form.component.html + 37,39 + + + + Category + + src/app/features/organization/features/inventory/ui/forms/inventory-reference-form/inventory-reference-form.component.html + 47,49 + + + + Consumable + + src/app/features/organization/features/inventory/ui/forms/inventory-reference-form/inventory-reference-form.component.html + 67,69 + + + src/app/features/organization/features/inventory/ui/pages/inventory-page/inventory-page.component.ts + 591 + + + + The reference could not be saved + + src/app/features/organization/features/inventory/ui/forms/inventory-reference-form/inventory-reference-form.component.html + 76,77 + + + + Enter a permanent code. + + src/app/features/organization/features/inventory/ui/forms/inventory-reference-form/inventory-reference-form.component.ts + 223 + + + + Enter a name. + + src/app/features/organization/features/inventory/ui/forms/inventory-reference-form/inventory-reference-form.component.ts + 229 + + + + Enter a stock unit. + + src/app/features/organization/features/inventory/ui/forms/inventory-reference-form/inventory-reference-form.component.ts + 236 + + + + Track parts and consumables separately from individual equipment. Confirmed movements update stock; retained declarations stay visible until reconciled. + + src/app/features/organization/features/inventory/ui/pages/inventory-page/inventory-page.component.html + 4,7 + + + + New part or consumable + + src/app/features/organization/features/inventory/ui/pages/inventory-page/inventory-page.component.html + 16,18 + + + + New warehouse + + src/app/features/organization/features/inventory/ui/pages/inventory-page/inventory-page.component.html + 18,21 + + + + Record stock correction + + src/app/features/organization/features/inventory/ui/pages/inventory-page/inventory-page.component.html + 31,35 + + + src/app/features/organization/features/inventory/ui/pages/inventory-page/inventory-page.component.ts + 566 + + + + Inventory sections + + src/app/features/organization/features/inventory/ui/pages/inventory-page/inventory-page.component.html + 37,38 + + + + Stock confirmation requires a connection + + src/app/features/organization/features/inventory/ui/pages/inventory-page/inventory-page.component.html + 58,60 + + + + Reconnect to read current stock or confirm a movement. Unacknowledged movements remain saved on this device. + + src/app/features/organization/features/inventory/ui/pages/inventory-page/inventory-page.component.html + 61,64 + + + + Saved movements could not be read + + src/app/features/organization/features/inventory/ui/pages/inventory-page/inventory-page.component.html + 68,69 + + + + Movements awaiting confirmation + + src/app/features/organization/features/inventory/ui/pages/inventory-page/inventory-page.component.html + 92,94 + + + + The response may have been lost. Retry the saved movement to obtain confirmation with the same identity. + + src/app/features/organization/features/inventory/ui/pages/inventory-page/inventory-page.component.html + 95,98 + + + + Return + + src/app/features/organization/features/inventory/ui/pages/inventory-page/inventory-page.component.html + 106,108 + + + src/app/features/organization/features/inventory/ui/pages/inventory-page/inventory-page.component.ts + 84 + + + + Correction + + src/app/features/organization/features/inventory/ui/pages/inventory-page/inventory-page.component.html + 108,110 + + + src/app/features/organization/features/inventory/ui/pages/inventory-page/inventory-page.component.ts + 85 + + + + Retry saved movement + + src/app/features/organization/features/inventory/ui/pages/inventory-page/inventory-page.component.html + 130,132 + + + src/app/features/organization/features/inventory/ui/pages/inventory-page/inventory-page.component.html + 342,344 + + + + Search by code or name + + src/app/features/organization/features/inventory/ui/pages/inventory-page/inventory-page.component.html + 141,143 + + + + Reference status + + src/app/features/organization/features/inventory/ui/pages/inventory-page/inventory-page.component.html + 147,149 + + + + Active + + src/app/features/organization/features/inventory/ui/pages/inventory-page/inventory-page.component.html + 158,159 + + + src/app/features/organization/features/inventory/ui/pages/inventory-page/inventory-page.component.ts + 595 + + + src/app/features/organization/features/inventory/ui/pages/inventory-page/inventory-page.component.ts + 607 + + + + Archived + + src/app/features/organization/features/inventory/ui/pages/inventory-page/inventory-page.component.html + 161,162 + + + src/app/features/organization/features/inventory/ui/pages/inventory-page/inventory-page.component.ts + 594 + + + src/app/features/organization/features/inventory/ui/pages/inventory-page/inventory-page.component.ts + 606 + + + + Declaration status + + src/app/features/organization/features/inventory/ui/pages/inventory-page/inventory-page.component.html + 191,193 + + + + All + + src/app/features/organization/features/inventory/ui/pages/inventory-page/inventory-page.component.html + 203,204 + + + + Received — to reconcile + + src/app/features/organization/features/inventory/ui/pages/inventory-page/inventory-page.component.html + 211,212 + + + src/app/features/organization/features/inventory/ui/pages/inventory-page/inventory-page.component.ts + 624 + + + + Confirmed + + src/app/features/organization/features/inventory/ui/pages/inventory-page/inventory-page.component.html + 219,221 + + + src/app/features/organization/features/inventory/ui/pages/inventory-page/inventory-page.component.ts + 623 + + + + Apply filters + + src/app/features/organization/features/inventory/ui/pages/inventory-page/inventory-page.component.html + 233,236 + + + + A declaration is a retained physical fact + + src/app/features/organization/features/inventory/ui/pages/inventory-page/inventory-page.component.html + 239,241 + + + + A pending declaration does not debit part of the stock or complete the work. Reconciliation confirms the entire quantity; dossier validation remains an intervention decision. + + src/app/features/organization/features/inventory/ui/pages/inventory-page/inventory-page.component.html + 242,245 + + + + Stock records could not be loaded + + src/app/features/organization/features/inventory/ui/pages/inventory-page/inventory-page.component.html + 250,251 + + + + The operation could not be confirmed + + src/app/features/organization/features/inventory/ui/pages/inventory-page/inventory-page.component.html + 267,268 + + + src/app/features/organization/features/inventory/ui/pages/inventory-page/inventory-page.component.html + 317,319 + + + + Preserve stable references and record motivated stock movements without changing history. + + src/app/features/organization/features/inventory/ui/pages/inventory-page/inventory-page.component.html + 300,301 + + + + This movement keeps one identity + + src/app/features/organization/features/inventory/ui/pages/inventory-page/inventory-page.component.html + 306,307 + + + + Keep this identity when retrying. The stock is confirmed only by the server response. + + src/app/features/organization/features/inventory/ui/pages/inventory-page/inventory-page.component.html + 311,313 + + + + Close + + src/app/features/organization/features/inventory/ui/pages/inventory-page/inventory-page.component.html + 332,333 + + + + Receipt + + src/app/features/organization/features/inventory/ui/pages/inventory-page/inventory-page.component.ts + 81 + + + + Supplier return + + src/app/features/organization/features/inventory/ui/pages/inventory-page/inventory-page.component.ts + 82 + + + + Consumption + + src/app/features/organization/features/inventory/ui/pages/inventory-page/inventory-page.component.ts + 83 + + + + No stock balance is available for this part and warehouse. + + src/app/features/organization/features/inventory/ui/pages/inventory-page/inventory-page.component.ts + 94 + + + + This part or warehouse is archived. Review the retained declaration before reconciling. + + src/app/features/organization/features/inventory/ui/pages/inventory-page/inventory-page.component.ts + 95 + + + + The declared quantity exceeds available stock. The full declaration is retained without a partial debit. + + src/app/features/organization/features/inventory/ui/pages/inventory-page/inventory-page.component.ts + 96 + + + + Part or consumable + + src/app/features/organization/features/inventory/ui/pages/inventory-page/inventory-page.component.ts + 561 + + + + Warehouse + + src/app/features/organization/features/inventory/ui/pages/inventory-page/inventory-page.component.ts + 563 + + + + Economic pilotage + + src/app/features/organization/features/maintenance-costs/maintenance-costs.routes.ts + 23 + + + src/app/features/organization/features/maintenance-costs/maintenance-costs.routes.ts + 25 + + + src/app/features/organization/navigation/organization-navigation.config.ts + 460 + + + + Maintenance costs + + src/app/features/organization/features/maintenance-costs/maintenance-costs.routes.ts + 39 + + + src/app/features/organization/features/maintenance-costs/maintenance-costs.routes.ts + 41 + + + + The previous declaration has no confirmed result. Retry it unchanged before creating another. + + src/app/features/organization/features/maintenance-costs/state/maintenance-cost/maintenance-cost.store.ts + 328 + + + src/app/features/organization/features/maintenance-costs/state/maintenance-cost/maintenance-cost.store.ts + 377 + + + src/app/features/organization/features/maintenance-costs/state/maintenance-cost/maintenance-cost.store.ts + 452 + + + + Financial information saved. + + src/app/features/organization/features/maintenance-costs/state/maintenance-cost/maintenance-cost.store.ts + 436 + + + + Financial information could not be saved. + + src/app/features/organization/features/maintenance-costs/state/maintenance-cost/maintenance-cost.store.ts + 462 + + + + Financial response does not match the requested scope. + + src/app/features/organization/features/maintenance-costs/state/maintenance-report/maintenance-report.store.ts + 109 + + + + Traceable adjustment + + src/app/features/organization/features/maintenance-costs/ui/components/maintenance-cost-facts/maintenance-cost-facts.component.html + 15,18 + + + src/app/features/organization/features/maintenance-costs/ui/components/maintenance-cost-facts/maintenance-cost-facts.component.html + 79,81 + + + + Occurred on + + src/app/features/organization/features/maintenance-costs/ui/components/maintenance-cost-facts/maintenance-cost-facts.component.html + 21,23 + + + src/app/features/organization/features/maintenance-costs/ui/components/maintenance-cost-facts/maintenance-cost-facts.component.html + 59,60 + + + + Exact amount + + src/app/features/organization/features/maintenance-costs/ui/components/maintenance-cost-facts/maintenance-cost-facts.component.html + 27,29 + + + src/app/features/organization/features/maintenance-costs/ui/components/maintenance-cost-facts/maintenance-cost-facts.component.html + 60,62 + + + + Adjust expense + + src/app/features/organization/features/maintenance-costs/ui/components/maintenance-cost-facts/maintenance-cost-facts.component.html + 44,46 + + + src/app/features/organization/features/maintenance-costs/ui/components/maintenance-cost-facts/maintenance-cost-facts.component.html + 101,102 + + + + Private maintenance cost contributions + + src/app/features/organization/features/maintenance-costs/ui/components/maintenance-cost-facts/maintenance-cost-facts.component.html + 54,56 + + + + Contribution + + src/app/features/organization/features/maintenance-costs/ui/components/maintenance-cost-facts/maintenance-cost-facts.component.html + 58,59 + + + + Actions + + src/app/features/organization/features/maintenance-costs/ui/components/maintenance-cost-facts/maintenance-cost-facts.component.html + 62,65 + + + + No cost contributions recorded + + src/app/features/organization/features/maintenance-costs/ui/components/maintenance-cost-facts/maintenance-cost-facts.component.html + 115,116 + + + + Recorded work time, material use and external expenses will appear here. + + src/app/features/organization/features/maintenance-costs/ui/components/maintenance-cost-facts/maintenance-cost-facts.component.html + 117,122 + + + + Work time + + src/app/features/organization/features/maintenance-costs/ui/components/maintenance-cost-facts/maintenance-cost-facts.component.ts + 154 + + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-planning-form/maintenance-planning-form.component.html + 69,71 + + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-planning-form/maintenance-planning-form.component.ts + 215 + + + + Materials + + src/app/features/organization/features/maintenance-costs/ui/components/maintenance-cost-facts/maintenance-cost-facts.component.ts + 156 + + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-planning-form/maintenance-planning-form.component.html + 72,74 + + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-planning-form/maintenance-planning-form.component.ts + 219 + + + + External expense + + src/app/features/organization/features/maintenance-costs/ui/components/maintenance-cost-facts/maintenance-cost-facts.component.ts + 158 + + + + Organization currency + + src/app/features/organization/features/maintenance-costs/ui/components/maintenance-cost-settings/maintenance-cost-settings.component.html + 4,5 + + + + Operational costs share one currency. It is locked after the first financial record. + + src/app/features/organization/features/maintenance-costs/ui/components/maintenance-cost-settings/maintenance-cost-settings.component.html + 6,9 + + + + Loading currency… + + src/app/features/organization/features/maintenance-costs/ui/components/maintenance-cost-settings/maintenance-cost-settings.component.html + 13,17 + + + + Currency could not be loaded + + src/app/features/organization/features/maintenance-costs/ui/components/maintenance-cost-settings/maintenance-cost-settings.component.html + 21,23 + + + + Reload currency + + src/app/features/organization/features/maintenance-costs/ui/components/maintenance-cost-settings/maintenance-cost-settings.component.html + 34,37 + + + + Current currency + + src/app/features/organization/features/maintenance-costs/ui/components/maintenance-cost-settings/maintenance-cost-settings.component.html + 45,47 + + + + Locked + + src/app/features/organization/features/maintenance-costs/ui/components/maintenance-cost-settings/maintenance-cost-settings.component.html + 51,55 + + + + Financial records already use this currency. It can no longer be changed. + + src/app/features/organization/features/maintenance-costs/ui/components/maintenance-cost-settings/maintenance-cost-settings.component.html + 61,64 + + + + No currency has been set. + + src/app/features/organization/features/maintenance-costs/ui/components/maintenance-cost-settings/maintenance-cost-settings.component.html + 66,69 + + + + Hourly rate history + + src/app/features/organization/features/maintenance-costs/ui/components/maintenance-cost-settings/maintenance-cost-settings.component.html + 83,84 + + + + Rates are dated and immutable. Add a new rate when an amount changes. + + src/app/features/organization/features/maintenance-costs/ui/components/maintenance-cost-settings/maintenance-cost-settings.component.html + 85,88 + + + + Loading rate history… + + src/app/features/organization/features/maintenance-costs/ui/components/maintenance-cost-settings/maintenance-cost-settings.component.html + 92,96 + + + + Rate history could not be loaded + + src/app/features/organization/features/maintenance-costs/ui/components/maintenance-cost-settings/maintenance-cost-settings.component.html + 100,102 + + + + Reload rate history + + src/app/features/organization/features/maintenance-costs/ui/components/maintenance-cost-settings/maintenance-cost-settings.component.html + 113,115 + + + + Hourly amount + + src/app/features/organization/features/maintenance-costs/ui/components/maintenance-cost-settings/maintenance-cost-settings.component.html + 127,128 + + + src/app/features/organization/features/maintenance-costs/ui/components/maintenance-cost-settings/maintenance-cost-settings.component.html + 153,154 + + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-rate-form/maintenance-rate-form.component.html + 105,108 + + + + Effective from + + src/app/features/organization/features/maintenance-costs/ui/components/maintenance-cost-settings/maintenance-cost-settings.component.html + 136,138 + + + src/app/features/organization/features/maintenance-costs/ui/components/maintenance-cost-settings/maintenance-cost-settings.component.html + 155,157 + + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-rate-form/maintenance-rate-form.component.html + 133,136 + + + + Immutable hourly rates by member and effective date + + src/app/features/organization/features/maintenance-costs/ui/components/maintenance-cost-settings/maintenance-cost-settings.component.html + 148,150 + + + + Member + + src/app/features/organization/features/maintenance-costs/ui/components/maintenance-cost-settings/maintenance-cost-settings.component.html + 152,153 + + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-rate-form/maintenance-rate-form.component.html + 51,53 + + + + No hourly rates yet + + src/app/features/organization/features/maintenance-costs/ui/components/maintenance-cost-settings/maintenance-cost-settings.component.html + 177,179 + + + + No hourly rates have been recorded for this organization. + + src/app/features/organization/features/maintenance-costs/ui/components/maintenance-cost-settings/maintenance-cost-settings.component.html + 180,182 + + + + Try loading this information again. + + src/app/features/organization/features/maintenance-costs/ui/components/maintenance-cost-settings/maintenance-cost-settings.component.ts + 480 + + + + Member details unavailable + + src/app/features/organization/features/maintenance-costs/ui/components/maintenance-cost-settings/maintenance-cost-settings.component.ts + 503 + + + + Financial dossiers + + src/app/features/organization/features/maintenance-costs/ui/dataviews/maintenance-financial-directory/maintenance-financial-directory.component.html + 8,10 + + + + Find a work reference or name, open its private financial dossier, or narrow the economic report using its named scope. + + src/app/features/organization/features/maintenance-costs/ui/dataviews/maintenance-financial-directory/maintenance-financial-directory.component.html + 11,16 + + + + Work reference or name + + src/app/features/organization/features/maintenance-costs/ui/dataviews/maintenance-financial-directory/maintenance-financial-directory.component.html + 26,27 + + + + Search dossiers + + src/app/features/organization/features/maintenance-costs/ui/dataviews/maintenance-financial-directory/maintenance-financial-directory.component.html + 42,44 + + + + Clear search + + src/app/features/organization/features/maintenance-costs/ui/dataviews/maintenance-financial-directory/maintenance-financial-directory.component.html + 54,58 + + + src/app/features/organization/features/maintenance-costs/ui/dataviews/maintenance-financial-directory/maintenance-financial-directory.component.html + 238,241 + + + + Reading financial dossiers + + src/app/features/organization/features/maintenance-costs/ui/dataviews/maintenance-financial-directory/maintenance-financial-directory.component.html + 62,66 + + + + Financial dossiers could not be loaded + + src/app/features/organization/features/maintenance-costs/ui/dataviews/maintenance-financial-directory/maintenance-financial-directory.component.html + 71,73 + + + src/app/features/organization/features/maintenance-costs/ui/dataviews/maintenance-financial-directory/maintenance-financial-directory.component.html + 95,97 + + + + Retry directory + + src/app/features/organization/features/maintenance-costs/ui/dataviews/maintenance-financial-directory/maintenance-financial-directory.component.html + 84,86 + + + src/app/features/organization/features/maintenance-costs/ui/dataviews/maintenance-financial-directory/maintenance-financial-directory.component.html + 108,110 + + + + Historical identity is incomplete. Only available named filters can be selected. + + src/app/features/organization/features/maintenance-costs/ui/dataviews/maintenance-financial-directory/maintenance-financial-directory.component.html + 129,132 + + + + Published on + + src/app/features/organization/features/maintenance-costs/ui/dataviews/maintenance-financial-directory/maintenance-financial-directory.component.html + 134,135 + + + + Limit to site + + src/app/features/organization/features/maintenance-costs/ui/dataviews/maintenance-financial-directory/maintenance-financial-directory.component.html + 149,150 + + + + Limit to customer + + src/app/features/organization/features/maintenance-costs/ui/dataviews/maintenance-financial-directory/maintenance-financial-directory.component.html + 164,165 + + + + Equipment report filters + + src/app/features/organization/features/maintenance-costs/ui/dataviews/maintenance-financial-directory/maintenance-financial-directory.component.html + 174,175 + + + + Limit to equipment + + src/app/features/organization/features/maintenance-costs/ui/dataviews/maintenance-financial-directory/maintenance-financial-directory.component.html + 188,189 + + + + Open financial dossier + + src/app/features/organization/features/maintenance-costs/ui/dataviews/maintenance-financial-directory/maintenance-financial-directory.component.html + 204,207 + + + + No financial dossiers in this scope + + src/app/features/organization/features/maintenance-costs/ui/dataviews/maintenance-financial-directory/maintenance-financial-directory.component.html + 222,224 + + + + Try another work reference, date window or named filter. + + src/app/features/organization/features/maintenance-costs/ui/dataviews/maintenance-financial-directory/maintenance-financial-directory.component.html + 225,228 + + + + Use at most 160 characters for the work reference or name. + + src/app/features/organization/features/maintenance-costs/ui/dataviews/maintenance-financial-directory/maintenance-financial-directory.component.ts + 368 + + + + Unnamed equipment + + src/app/features/organization/features/maintenance-costs/ui/dataviews/maintenance-financial-directory/maintenance-financial-directory.component.ts + 469 + + + + Published snapshot + + src/app/features/organization/features/maintenance-costs/ui/dataviews/maintenance-financial-directory/maintenance-financial-directory.component.ts + 534 + + + + Historical snapshot missing + + src/app/features/organization/features/maintenance-costs/ui/dataviews/maintenance-financial-directory/maintenance-financial-directory.component.ts + 536 + + + + Current work + + src/app/features/organization/features/maintenance-costs/ui/dataviews/maintenance-financial-directory/maintenance-financial-directory.component.ts + 538 + + + + Currency code + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-currency-form/maintenance-currency-form.component.html + 13,17 + + + + Use a three-letter uppercase code, for example EUR. Currency is locked after the first financial record. + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-currency-form/maintenance-currency-form.component.html + 25,28 + + + + Currency could not be saved + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-currency-form/maintenance-currency-form.component.html + 38,40 + + + + Save currency + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-currency-form/maintenance-currency-form.component.html + 54,59 + + + + Your currency draft is preserved. Try saving again. + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-currency-form/maintenance-currency-form.component.ts + 159 + + + + Enter a three-letter uppercase currency code. + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-currency-form/maintenance-currency-form.component.ts + 180 + + + + Adjust the original expense + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-expense-form/maintenance-expense-form.component.html + 10,12 + + + + Add an external expense + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-expense-form/maintenance-expense-form.component.html + 12,15 + + + + Expenses and corrections are appended to the history. The closure snapshot remains unchanged. + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-expense-form/maintenance-expense-form.component.html + 16,18 + + + + Exact amount + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-expense-form/maintenance-expense-form.component.html + 27,30 + + + + Actual date and time + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-expense-form/maintenance-expense-form.component.html + 43,46 + + + + UTC offset for this repeated hour + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-expense-form/maintenance-expense-form.component.html + 65,67 + + + + Choose the actual UTC offset + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-expense-form/maintenance-expense-form.component.html + 72,73 + + + + The same local time occurred twice because the clocks changed. Select the UTC offset when the expense actually occurred. + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-expense-form/maintenance-expense-form.component.html + 86,89 + + + + Description or correction reason + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-expense-form/maintenance-expense-form.component.html + 102,105 + + + + Record adjustment + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-expense-form/maintenance-expense-form.component.html + 126,128 + + + + Record expense + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-expense-form/maintenance-expense-form.component.html + 128,131 + + + + Cancel adjustment + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-expense-form/maintenance-expense-form.component.html + 141,145 + + + + Enter the exact expense amount. + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-expense-form/maintenance-expense-form.component.ts + 305 + + + + Enter an exact decimal with at most six decimal places. A negative correction requires its original expense. + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-expense-form/maintenance-expense-form.component.ts + 312 + + + + Describe the expense or explain its correction. + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-expense-form/maintenance-expense-form.component.ts + 316 + + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-expense-form/maintenance-expense-form.component.ts + 324 + + + + Enter the actual expense date and time. + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-expense-form/maintenance-expense-form.component.ts + 328 + + + + Enter a valid actual date that is not in the future. + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-expense-form/maintenance-expense-form.component.ts + 339 + + + + This local hour occurs twice. Choose the UTC offset of the actual expense. + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-expense-form/maintenance-expense-form.component.ts + 347 + + + + Financial preparation + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-planning-form/maintenance-planning-form.component.html + 8,9 + + + + Leave an estimate blank when it is unknown. Zero is a known value. + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-planning-form/maintenance-planning-form.component.html + 10,12 + + + + Planned budget + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-planning-form/maintenance-planning-form.component.html + 15,18 + + + src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-costs-page/maintenance-costs-page.component.html + 132,134 + + + src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-costs-page/maintenance-costs-page.component.html + 235,237 + + + + Estimated minutes + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-planning-form/maintenance-planning-form.component.html + 34,37 + + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-planning-form/maintenance-planning-form.component.html + 125,128 + + + + Estimated resource + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-planning-form/maintenance-planning-form.component.html + 52,53 + + + + Resource type + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-planning-form/maintenance-planning-form.component.html + 59,61 + + + + External service + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-planning-form/maintenance-planning-form.component.html + 75,77 + + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-planning-form/maintenance-planning-form.component.ts + 217 + + + + Description + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-planning-form/maintenance-planning-form.component.html + 85,88 + + + + Unit or hourly amount + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-planning-form/maintenance-planning-form.component.html + 104,107 + + + + Quantity + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-planning-form/maintenance-planning-form.component.html + 145,148 + + + + Estimated amount + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-planning-form/maintenance-planning-form.component.html + 166,169 + + + + Remove resource + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-planning-form/maintenance-planning-form.component.html + 191,195 + + + + Add estimated resource + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-planning-form/maintenance-planning-form.component.html + 204,207 + + + + The financial preparation has changed + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-planning-form/maintenance-planning-form.component.html + 209,211 + + + + Your edits are retained. Compare them with the refreshed budget before using its latest version. + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-planning-form/maintenance-planning-form.component.html + 212,216 + + + + Use the latest version for these edits + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-planning-form/maintenance-planning-form.component.html + 224,228 + + + + Save financial preparation + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-planning-form/maintenance-planning-form.component.html + 240,243 + + + + Describe this estimated resource. + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-planning-form/maintenance-planning-form.component.ts + 374 + + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-planning-form/maintenance-planning-form.component.ts + 382 + + + + Enter a non-negative decimal with at most six decimal places. + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-planning-form/maintenance-planning-form.component.ts + 579 + + + + Enter whole minutes between zero and 100000000. + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-planning-form/maintenance-planning-form.component.ts + 596 + + + + The rate submission is awaiting confirmation + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-rate-form/maintenance-rate-form.component.html + 12,14 + + + + Keep this draft unchanged. Retry the original submission to confirm whether the rate was recorded. + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-rate-form/maintenance-rate-form.component.html + 16,19 + + + + Retry original submission + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-rate-form/maintenance-rate-form.component.html + 29,31 + + + + The rate could not be confirmed + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-rate-form/maintenance-rate-form.component.html + 38,40 + + + + Add an hourly rate + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-rate-form/maintenance-rate-form.component.html + 44,45 + + + + A new rate takes effect from its date. Earlier rates and recorded costs stay unchanged. + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-rate-form/maintenance-rate-form.component.html + 46,49 + + + + Choose a member + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-rate-form/maintenance-rate-form.component.html + 61,62 + + + + Loading members… + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-rate-form/maintenance-rate-form.component.html + 83,86 + + + + No available members. Refresh the member directory before adding a rate. + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-rate-form/maintenance-rate-form.component.html + 88,90 + + + + Member directory access is required to choose a member and add a rate. + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-rate-form/maintenance-rate-form.component.html + 93,96 + + + + per hour. Use a decimal point and up to six decimal places. + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-rate-form/maintenance-rate-form.component.html + 119,122 + + + + This rate applies from the selected calendar day. + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-rate-form/maintenance-rate-form.component.html + 143,145 + + + + Set the organization currency before adding a rate. + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-rate-form/maintenance-rate-form.component.html + 156,159 + + + + Add rate + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-rate-form/maintenance-rate-form.component.html + 178,181 + + + + Your rate draft is preserved. Try again when the request has settled. + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-rate-form/maintenance-rate-form.component.ts + 240 + + + + Choose an available organization member. + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-rate-form/maintenance-rate-form.component.ts + 291 + + + + Enter a nonnegative amount with no more than six decimal places, using a decimal point. + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-rate-form/maintenance-rate-form.component.ts + 299 + + + + Choose a valid effective date. + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-rate-form/maintenance-rate-form.component.ts + 307 + + + + Report scope + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-report-filter-form/maintenance-report-filter-form.component.html + 8,9 + + + + Choose up to 366 calendar days. The first and last dates are included. + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-report-filter-form/maintenance-report-filter-form.component.html + 10,12 + + + + From + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-report-filter-form/maintenance-report-filter-form.component.html + 18,20 + + + + Through + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-report-filter-form/maintenance-report-filter-form.component.html + 29,31 + + + + Group costs by + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-report-filter-form/maintenance-report-filter-form.component.html + 44,47 + + + + Equipment + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-report-filter-form/maintenance-report-filter-form.component.html + 61,64 + + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-report-filter-form/maintenance-report-filter-form.component.html + 130,131 + + + + Site + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-report-filter-form/maintenance-report-filter-form.component.html + 70,73 + + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-report-filter-form/maintenance-report-filter-form.component.html + 93,95 + + + + Customer + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-report-filter-form/maintenance-report-filter-form.component.html + 79,82 + + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-report-filter-form/maintenance-report-filter-form.component.html + 111,113 + + + + Selected filters + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-report-filter-form/maintenance-report-filter-form.component.html + 87,89 + + + + Clear site filter + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-report-filter-form/maintenance-report-filter-form.component.html + 104,107 + + + + Clear customer filter + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-report-filter-form/maintenance-report-filter-form.component.html + 123,126 + + + + Unnamed equipment + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-report-filter-form/maintenance-report-filter-form.component.html + 135,139 + + + + Clear equipment filter + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-report-filter-form/maintenance-report-filter-form.component.html + 148,151 + + + + Clear all named filters + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-report-filter-form/maintenance-report-filter-form.component.html + 161,164 + + + + Read economic report + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-report-filter-form/maintenance-report-filter-form.component.html + 175,178 + + + + Choose the first report date. + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-report-filter-form/maintenance-report-filter-form.component.ts + 252 + + + + Choose the last report date. + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-report-filter-form/maintenance-report-filter-form.component.ts + 255 + + + + Choose an ordered date window of at most 366 days, including both dates. + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-report-filter-form/maintenance-report-filter-form.component.ts + 272 + + + + Choose equipment, site or customer grouping. + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-report-filter-form/maintenance-report-filter-form.component.ts + 280 + + + + Choose a valid calendar date. + + src/app/features/organization/features/maintenance-costs/ui/forms/maintenance-report-filter-form/maintenance-report-filter-form.component.ts + 388 + + + + Prepare budgets and follow internal operational costs. Commercial invoicing remains in your ERP. + + src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-costs-page/maintenance-costs-page.component.html + 4,7 + + + + Private financial information + + src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-costs-page/maintenance-costs-page.component.html + 8,11 + + + src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-reports-page/maintenance-reports-page.component.html + 9,13 + + + + Open economic pilotage + + src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-costs-page/maintenance-costs-page.component.html + 19,22 + + + + Wait for the current declaration to finish before leaving this page. + + src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-costs-page/maintenance-costs-page.component.html + 24,28 + + + + Could not recover previous declarations + + src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-costs-page/maintenance-costs-page.component.html + 31,33 + + + + New declarations stay unavailable until recovery succeeds. + + src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-costs-page/maintenance-costs-page.component.html + 34,37 + + + + Retry recovery + + src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-costs-page/maintenance-costs-page.component.html + 44,47 + + + + Confirmation is still needed + + src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-costs-page/maintenance-costs-page.component.html + 50,51 + + + + Your original declaration is retained. Retry it unchanged to confirm whether it was saved. + + src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-costs-page/maintenance-costs-page.component.html + 52,55 + + + + Retry the original declaration + + src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-costs-page/maintenance-costs-page.component.html + 63,67 + + + + Intervention costs + + src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-costs-page/maintenance-costs-page.component.html + 74,76 + + + + Currency and hourly rates + + src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-costs-page/maintenance-costs-page.component.html + 81,83 + + + + Could not load the financial dossier + + src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-costs-page/maintenance-costs-page.component.html + 94,96 + + + + Reload + + src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-costs-page/maintenance-costs-page.component.html + 105,107 + + + + Current realized costs + + src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-costs-page/maintenance-costs-page.component.html + 111,112 + + + + Open intervention + + src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-costs-page/maintenance-costs-page.component.html + 125,129 + + + + Known contributions + + src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-costs-page/maintenance-costs-page.component.html + 140,142 + + + src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-costs-page/maintenance-costs-page.component.html + 249,250 + + + + Complete realized total + + src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-costs-page/maintenance-costs-page.component.html + 148,150 + + + src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-costs-page/maintenance-costs-page.component.html + 255,256 + + + + The valuation is incomplete + + src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-costs-page/maintenance-costs-page.component.html + 158,160 + + + + Some prices or hourly rates are unknown. Known contributions are shown separately and missing values are not treated as zero. + + src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-costs-page/maintenance-costs-page.component.html + 161,163 + + + + Financial preparation is closed. The recorded budget is retained. + + src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-costs-page/maintenance-costs-page.component.html + 187,190 + + + + Read the latest budget for comparison + + src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-costs-page/maintenance-costs-page.component.html + 199,201 + + + + Costs recorded at closure + + src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-costs-page/maintenance-costs-page.component.html + 220,221 + + + + Immutable snapshot + + src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-costs-page/maintenance-costs-page.component.html + 222,224 + + + + Later expenses, returns and corrected time affect the current costs while this original snapshot stays unchanged. + + src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-costs-page/maintenance-costs-page.component.html + 226,228 + + + + Captured on + + src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-costs-page/maintenance-costs-page.component.html + 243,245 + + + + Some valuations were unknown at closure. + + src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-costs-page/maintenance-costs-page.component.html + 267,269 + + + + Choose an intervention + + src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-costs-page/maintenance-costs-page.component.html + 276,277 + + + + Open an intervention's financial dossier to prepare its budget and follow its costs. + + src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-costs-page/maintenance-costs-page.component.html + 278,280 + + + + Browse interventions + + src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-costs-page/maintenance-costs-page.component.html + 287,290 + + + + Financial information is available only in an authorized browser session. + + src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-costs-page/maintenance-costs-page.component.html + 329,333 + + + src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-reports-page/maintenance-reports-page.component.html + 389,393 + + + + Compare internal maintenance spending with preparation, by equipment, site or customer. Each contribution is allocated once and unknown valuations remain visible. + + src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-reports-page/maintenance-reports-page.component.html + 4,7 + + + + Financial dossiers and settings + + src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-reports-page/maintenance-reports-page.component.html + 18,22 + + + + Browse financial dossiers and choose a scope + + src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-reports-page/maintenance-reports-page.component.html + 48,50 + + + + Search the paginated financial directory to select a named site, customer or equipment. These minimal contexts are available with financial read permission alone. + + src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-reports-page/maintenance-reports-page.component.html + 52,55 + + + + Could not prepare the financial report + + src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-reports-page/maintenance-reports-page.component.html + 77,79 + + + + No partial total is displayed. Check the dates and use a shorter period if the report is too large. Named target filters refine the selected dossiers; they do not increase the period's source limit. + + src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-reports-page/maintenance-reports-page.component.html + 81,85 + + + + Retry report + + src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-reports-page/maintenance-reports-page.component.html + 94,96 + + + + Preparing the financial report + + src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-reports-page/maintenance-reports-page.component.html + 102,104 + + + + Filtered maintenance totals + + src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-reports-page/maintenance-reports-page.component.html + 112,113 + + + + Source dossiers: + + src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-reports-page/maintenance-reports-page.component.html + 119,120 + + + + These totals cover all filtered destinations and unallocated contributions, including results on other pages. Budget and resource estimates remain separate. + + src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-reports-page/maintenance-reports-page.component.html + 123,126 + + + + Resource estimates provide the planned cost. When a dossier has no estimated resources, its global budget serves as the forecast. The budget remains a separate metric and is never added to resource estimates. + + src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-reports-page/maintenance-reports-page.component.html + 127,131 + + + + The inclusive UTC date window selects published dossiers by publication date and open work by planned start, or creation date when no start is set. Costs show those dossiers' full current history, including later corrections. + + src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-reports-page/maintenance-reports-page.component.html + 132,135 + + + + Current realized cost + + src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-reports-page/maintenance-reports-page.component.html + 139,141 + + + + Known: + + src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-reports-page/maintenance-reports-page.component.html + 149,150 + + + src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-reports-page/maintenance-reports-page.component.html + 165,166 + + + src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-reports-page/maintenance-reports-page.component.html + 181,182 + + + src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-reports-page/maintenance-reports-page.component.html + 197,198 + + + src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-reports-page/maintenance-reports-page.component.html + 322,323 + + + + Recorded at closure + + src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-reports-page/maintenance-reports-page.component.html + 155,157 + + + + Planned cost + + src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-reports-page/maintenance-reports-page.component.html + 171,173 + + + + Global budgets + + src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-reports-page/maintenance-reports-page.component.html + 187,189 + + + + Realized cost minus estimate + + src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-reports-page/maintenance-reports-page.component.html + 203,205 + + + + Some valuations are unknown + + src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-reports-page/maintenance-reports-page.component.html + 213,215 + + + + Unknown prices, rates or estimates are not zero. Each incomplete metric shows its known subtotal, and a variance is available only when both amounts are complete. + + src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-reports-page/maintenance-reports-page.component.html + 216,219 + + + + Some historical closure snapshots are missing + + src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-reports-page/maintenance-reports-page.component.html + 224,226 + + + + Published dossiers without an immutable closure snapshot: . Their current facts remain visible, while the missing closure values are explicit. + + src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-reports-page/maintenance-reports-page.component.html + 227,230 + + + + Allocated destinations + + src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-reports-page/maintenance-reports-page.component.html + 235,237 + + + + Unallocated contributions + + src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-reports-page/maintenance-reports-page.component.html + 249,250 + + + + Whole-dossier budgets, general expenses and unresolved identities remain here. They are counted once in the filtered totals and are never copied onto every equipment. + + src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-reports-page/maintenance-reports-page.component.html + 251,254 + + + + Allocation reconciliation + + src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-reports-page/maintenance-reports-page.component.html + 265,267 + + + + Original source dossiers equal the selected allocations plus other destinations excluded by the equipment scope. General unallocated facts stay visible. + + src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-reports-page/maintenance-reports-page.component.html + 270,273 + + + + Source dossiers, current cost + + src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-reports-page/maintenance-reports-page.component.html + 279,281 + + + + Other excluded allocations, current cost + + src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-reports-page/maintenance-reports-page.component.html + 290,292 + + + + Organization procurement overview + + src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-reports-page/maintenance-reports-page.component.html + 303,305 + + + + This organization-wide overview selects orders created in the date window, independently of equipment, site or customer filters. Deliveries and supplier returns cover the selected orders' entire histories. These purchase amounts are separate from consumed-piece costs. + + src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-reports-page/maintenance-reports-page.component.html + 306,310 + + + + Orders: · Receipts: · Receipts awaiting individual equipment: + + src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-reports-page/maintenance-reports-page.component.html + 329,333 + + + + Source financial dossiers + + src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-reports-page/maintenance-reports-page.component.html + 356,358 + + + + These are the exact dossiers contributing to the selected allocation. Open their private cost dossier to compare current facts with the immutable closure. + + src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-reports-page/maintenance-reports-page.component.html + 359,362 + + + + Open financial dossier + + src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-reports-page/maintenance-reports-page.component.html + 378,380 + + + + Committed purchases + + src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-reports-page/maintenance-reports-page.component.ts + 389 + + + + Gross deliveries + + src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-reports-page/maintenance-reports-page.component.ts + 393 + + + + Still to receive + + src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-reports-page/maintenance-reports-page.component.ts + 397 + + + + Declared supplier returns + + src/app/features/organization/features/maintenance-costs/ui/pages/maintenance-reports-page/maintenance-reports-page.component.ts + 401 + + + + Reading economic allocations + + src/app/features/organization/features/maintenance-costs/ui/tables/maintenance-report-table/maintenance-report-table.component.html + 9,13 + + + + Allocation incomplete + + src/app/features/organization/features/maintenance-costs/ui/tables/maintenance-report-table/maintenance-report-table.component.html + 30,34 + + + src/app/features/organization/features/maintenance-costs/ui/tables/maintenance-report-table/maintenance-report-table.component.html + 119,122 + + + + Known subtotal + + src/app/features/organization/features/maintenance-costs/ui/tables/maintenance-report-table/maintenance-report-table.component.html + 43,44 + + + src/app/features/organization/features/maintenance-costs/ui/tables/maintenance-report-table/maintenance-report-table.component.html + 135,136 + + + + Unvalued contributions + + src/app/features/organization/features/maintenance-costs/ui/tables/maintenance-report-table/maintenance-report-table.component.html + 47,48 + + + src/app/features/organization/features/maintenance-costs/ui/tables/maintenance-report-table/maintenance-report-table.component.html + 141,142 + + + + Realized minus forecast + + src/app/features/organization/features/maintenance-costs/ui/tables/maintenance-report-table/maintenance-report-table.component.html + 59,61 + + + src/app/features/organization/features/maintenance-costs/ui/tables/maintenance-report-table/maintenance-report-table.component.html + 99,101 + + + + Source dossiers + + src/app/features/organization/features/maintenance-costs/ui/tables/maintenance-report-table/maintenance-report-table.component.html + 67,68 + + + src/app/features/organization/features/maintenance-costs/ui/tables/maintenance-report-table/maintenance-report-table.component.html + 101,104 + + + + View source dossiers + + src/app/features/organization/features/maintenance-costs/ui/tables/maintenance-report-table/maintenance-report-table.component.html + 79,82 + + + src/app/features/organization/features/maintenance-costs/ui/tables/maintenance-report-table/maintenance-report-table.component.html + 164,166 + + + + Allocation + + src/app/features/organization/features/maintenance-costs/ui/tables/maintenance-report-table/maintenance-report-table.component.html + 94,95 + + + + Budget is shown separately from forecast cost. The variance compares current realized cost with the forecast. + + src/app/features/organization/features/maintenance-costs/ui/tables/maintenance-report-table/maintenance-report-table.component.html + 174,178 + + + + No costs allocated in this window + + src/app/features/organization/features/maintenance-costs/ui/tables/maintenance-report-table/maintenance-report-table.component.html + 181,183 + + + + Choose another date window or review the explicitly unallocated contributions. + + src/app/features/organization/features/maintenance-costs/ui/tables/maintenance-report-table/maintenance-report-table.component.html + 184,189 + + + + Economic cost allocations + + src/app/features/organization/features/maintenance-costs/ui/tables/maintenance-report-table/maintenance-report-table.component.ts + 122 + + + + Current realized cost + + src/app/features/organization/features/maintenance-costs/ui/tables/maintenance-report-table/maintenance-report-table.component.ts + 267 + + + + Cost at closure + + src/app/features/organization/features/maintenance-costs/ui/tables/maintenance-report-table/maintenance-report-table.component.ts + 269 + + + + Forecast cost + + src/app/features/organization/features/maintenance-costs/ui/tables/maintenance-report-table/maintenance-report-table.component.ts + 270 + + + + Budget + + src/app/features/organization/features/maintenance-costs/ui/tables/maintenance-report-table/maintenance-report-table.component.ts + 271 + + + + Historical identity unavailable + + src/app/features/organization/features/maintenance-costs/ui/tables/maintenance-report-table/maintenance-report-table.component.ts + 345 + + + + Unallocated costs + + src/app/features/organization/features/maintenance-costs/ui/tables/maintenance-report-table/maintenance-report-table.component.ts + 346 + + + + Identity recorded at closure + + src/app/features/organization/features/maintenance-costs/ui/tables/maintenance-report-table/maintenance-report-table.component.ts + 367 + + + + Current identity + + src/app/features/organization/features/maintenance-costs/ui/tables/maintenance-report-table/maintenance-report-table.component.ts + 369 + + + + Current and recorded identity + + src/app/features/organization/features/maintenance-costs/ui/tables/maintenance-report-table/maintenance-report-table.component.ts + 371 + + + + No allocation identity + + src/app/features/organization/features/maintenance-costs/ui/tables/maintenance-report-table/maintenance-report-table.component.ts + 373 + + + + Identity is incomplete + + src/app/features/organization/features/maintenance-costs/ui/tables/maintenance-report-table/maintenance-report-table.component.ts + 375 + + + + Unknown + + src/app/features/organization/features/maintenance-costs/utils/maintenance-amount/maintenance-amount.utils.ts + 20 + + + src/app/features/organization/features/maintenance-costs/utils/maintenance-amount/maintenance-amount.utils.ts + 22 + + + + Maintenance exports + + src/app/features/organization/features/maintenance-exports/maintenance-exports.routes.ts + 26 + + + src/app/features/organization/features/maintenance-exports/maintenance-exports.routes.ts + 28 + + + src/app/features/organization/navigation/organization-navigation.config.ts + 468 + + + + Actual external import reference + + src/app/features/organization/features/maintenance-exports/ui/forms/maintenance-export-action-form/maintenance-export-action-form.component.html + 13,17 + + + + Confirm only after your ERP has imported this exact archive. FireGuard does not send or import it automatically. + + src/app/features/organization/features/maintenance-exports/ui/forms/maintenance-export-action-form/maintenance-export-action-form.component.html + 22,26 + + + + Reason for adjustment + + src/app/features/organization/features/maintenance-exports/ui/forms/maintenance-export-action-form/maintenance-export-action-form.component.html + 30,33 + + + + Validated corrections produce a new linked archive. The original file and published dossier remain unchanged. + + src/app/features/organization/features/maintenance-exports/ui/forms/maintenance-export-action-form/maintenance-export-action-form.component.html + 39,43 + + + + Confirm actual import + + src/app/features/organization/features/maintenance-exports/ui/forms/maintenance-export-action-form/maintenance-export-action-form.component.html + 60,62 + + + + Generate adjustment + + src/app/features/organization/features/maintenance-exports/ui/forms/maintenance-export-action-form/maintenance-export-action-form.component.html + 62,65 + + + + Enter the reason or actual external import reference. + + src/app/features/organization/features/maintenance-exports/ui/forms/maintenance-export-action-form/maintenance-export-action-form.component.ts + 173 + + + src/app/features/organization/features/maintenance-exports/ui/forms/maintenance-export-action-form/maintenance-export-action-form.component.ts + 191 + + + + Use no more than 200 characters. + + src/app/features/organization/features/maintenance-exports/ui/forms/maintenance-export-action-form/maintenance-export-action-form.component.ts + 182 + + + src/app/features/organization/features/maintenance-exports/ui/forms/maintenance-export-reference-form/maintenance-export-reference-form.component.ts + 350 + + + + Use no more than 1000 characters. + + src/app/features/organization/features/maintenance-exports/ui/forms/maintenance-export-action-form/maintenance-export-action-form.component.ts + 183 + + + + External system code + + src/app/features/organization/features/maintenance-exports/ui/forms/maintenance-export-create-form/maintenance-export-create-form.component.html + 9,11 + + + src/app/features/organization/features/maintenance-exports/ui/forms/maintenance-export-reference-form/maintenance-export-reference-form.component.html + 13,16 + + + + Identifies your ERP or target system. References are frozen when the export is generated. + + src/app/features/organization/features/maintenance-exports/ui/forms/maintenance-export-create-form/maintenance-export-create-form.component.html + 13,15 + + + + Published dossiers + + src/app/features/organization/features/maintenance-exports/ui/forms/maintenance-export-create-form/maintenance-export-create-form.component.html + 22,24 + + + + Search published dossiers + + src/app/features/organization/features/maintenance-exports/ui/forms/maintenance-export-create-form/maintenance-export-create-form.component.html + 25,27 + + + + Search + + src/app/features/organization/features/maintenance-exports/ui/forms/maintenance-export-create-form/maintenance-export-create-form.component.html + 45,47 + + + src/app/features/organization/features/maintenance-exports/ui/forms/maintenance-export-reference-form/maintenance-export-reference-form.component.html + 113,115 + + + + Loading dossiers + + src/app/features/organization/features/maintenance-exports/ui/forms/maintenance-export-create-form/maintenance-export-create-form.component.html + 51,52 + + + + No published dossier matches this search. + + src/app/features/organization/features/maintenance-exports/ui/forms/maintenance-export-create-form/maintenance-export-create-form.component.html + 95,98 + + + + Page + + src/app/features/organization/features/maintenance-exports/ui/forms/maintenance-export-create-form/maintenance-export-create-form.component.html + 112,114 + + + + dossier(s) selected. Selection is kept across pages. + + src/app/features/organization/features/maintenance-exports/ui/forms/maintenance-export-create-form/maintenance-export-create-form.component.html + 128,130 + + + + Include internal costs in this private export + + src/app/features/organization/features/maintenance-exports/ui/forms/maintenance-export-create-form/maintenance-export-create-form.component.html + 143,146 + + + + Unknown costs remain explicitly incomplete. This archive will require financial read permission. + + src/app/features/organization/features/maintenance-exports/ui/forms/maintenance-export-create-form/maintenance-export-create-form.component.html + 147,151 + + + + Generate retained export + + src/app/features/organization/features/maintenance-exports/ui/forms/maintenance-export-create-form/maintenance-export-create-form.component.html + 161,164 + + + + Enter the external system code. + + src/app/features/organization/features/maintenance-exports/ui/forms/maintenance-export-create-form/maintenance-export-create-form.component.ts + 280 + + + src/app/features/organization/features/maintenance-exports/ui/forms/maintenance-export-reference-form/maintenance-export-reference-form.component.ts + 331 + + + + Use letters, numbers, dots, underscores or hyphens (up to 40 characters). + + src/app/features/organization/features/maintenance-exports/ui/forms/maintenance-export-create-form/maintenance-export-create-form.component.ts + 288 + + + src/app/features/organization/features/maintenance-exports/ui/forms/maintenance-export-reference-form/maintenance-export-reference-form.component.ts + 339 + + + + Select between 1 and 100 ready published dossiers. + + src/app/features/organization/features/maintenance-exports/ui/forms/maintenance-export-create-form/maintenance-export-create-form.component.ts + 296 + + + + Historical publication snapshot missing. This dossier cannot be exported. + + src/app/features/organization/features/maintenance-exports/ui/forms/maintenance-export-create-form/maintenance-export-create-form.component.ts + 393 + + + + No validated work is retained in this dossier. It cannot be exported. + + src/app/features/organization/features/maintenance-exports/ui/forms/maintenance-export-create-form/maintenance-export-create-form.component.ts + 394 + + + + Mapping changes affect future exports. Retained archives keep their original external references. + + src/app/features/organization/features/maintenance-exports/ui/forms/maintenance-export-reference-form/maintenance-export-reference-form.component.html + 8,12 + + + + A readable customer, site or equipment directory permission is required to choose a mapping target. + + src/app/features/organization/features/maintenance-exports/ui/forms/maintenance-export-reference-form/maintenance-export-reference-form.component.html + 29,34 + + + + Resource type + + src/app/features/organization/features/maintenance-exports/ui/forms/maintenance-export-reference-form/maintenance-export-reference-form.component.html + 39,41 + + + + Customer status + + src/app/features/organization/features/maintenance-exports/ui/forms/maintenance-export-reference-form/maintenance-export-reference-form.component.html + 54,57 + + + src/app/features/organization/features/maintenance-exports/ui/forms/maintenance-export-reference-form/maintenance-export-reference-form.component.html + 62,63 + + + + Archived customers can receive references for historical exports. + + src/app/features/organization/features/maintenance-exports/ui/forms/maintenance-export-reference-form/maintenance-export-reference-form.component.html + 85,89 + + + + Search the resource directory + + src/app/features/organization/features/maintenance-exports/ui/forms/maintenance-export-reference-form/maintenance-export-reference-form.component.html + 94,96 + + + + Scoped resource + + src/app/features/organization/features/maintenance-exports/ui/forms/maintenance-export-reference-form/maintenance-export-reference-form.component.html + 122,124 + + + + Loading resources + + src/app/features/organization/features/maintenance-exports/ui/forms/maintenance-export-reference-form/maintenance-export-reference-form.component.html + 141,142 + + + + Page + + src/app/features/organization/features/maintenance-exports/ui/forms/maintenance-export-reference-form/maintenance-export-reference-form.component.html + 163,164 + + + + External resource reference + + src/app/features/organization/features/maintenance-exports/ui/forms/maintenance-export-reference-form/maintenance-export-reference-form.component.html + 182,185 + + + + Reading current reference + + src/app/features/organization/features/maintenance-exports/ui/forms/maintenance-export-reference-form/maintenance-export-reference-form.component.html + 197,198 + + + + Save reviewed reference + + src/app/features/organization/features/maintenance-exports/ui/forms/maintenance-export-reference-form/maintenance-export-reference-form.component.html + 224,227 + + + + Select an authorized resource. + + src/app/features/organization/features/maintenance-exports/ui/forms/maintenance-export-reference-form/maintenance-export-reference-form.component.ts + 328 + + + + Enter the external resource reference. + + src/app/features/organization/features/maintenance-exports/ui/forms/maintenance-export-reference-form/maintenance-export-reference-form.component.ts + 343 + + + + Customer + + src/app/features/organization/features/maintenance-exports/ui/forms/maintenance-export-reference-form/maintenance-export-reference-form.component.ts + 368 + + + src/app/features/organization/features/maintenance-exports/ui/pages/maintenance-exports-page/maintenance-exports-page.component.ts + 704 + + + + Site + + src/app/features/organization/features/maintenance-exports/ui/forms/maintenance-export-reference-form/maintenance-export-reference-form.component.ts + 369 + + + src/app/features/organization/features/maintenance-exports/ui/pages/maintenance-exports-page/maintenance-exports-page.component.ts + 705 + + + + Equipment + + src/app/features/organization/features/maintenance-exports/ui/forms/maintenance-export-reference-form/maintenance-export-reference-form.component.ts + 370 + + + src/app/features/organization/features/maintenance-exports/ui/pages/maintenance-exports-page/maintenance-exports-page.component.ts + 706 + + + + Prestation exports + + src/app/features/organization/features/maintenance-exports/ui/pages/maintenance-exports-page/maintenance-exports-page.component.html + 4,5 + + + + Retain versioned CSV/JSON files and record actual ERP imports. Commercial invoicing remains in your ERP. + + src/app/features/organization/features/maintenance-exports/ui/pages/maintenance-exports-page/maintenance-exports-page.component.html + 6,10 + + + + Generate export + + src/app/features/organization/features/maintenance-exports/ui/pages/maintenance-exports-page/maintenance-exports-page.component.html + 21,23 + + + + Map a reference + + src/app/features/organization/features/maintenance-exports/ui/pages/maintenance-exports-page/maintenance-exports-page.component.html + 31,34 + + + + Schema version + + src/app/features/organization/features/maintenance-exports/ui/pages/maintenance-exports-page/maintenance-exports-page.component.html + 68,70 + + + + Retained prestation rows + + src/app/features/organization/features/maintenance-exports/ui/pages/maintenance-exports-page/maintenance-exports-page.component.html + 74,76 + + + + Published dossiers + + src/app/features/organization/features/maintenance-exports/ui/pages/maintenance-exports-page/maintenance-exports-page.component.html + 80,82 + + + + Open corrected export + + src/app/features/organization/features/maintenance-exports/ui/pages/maintenance-exports-page/maintenance-exports-page.component.html + 94,96 + + + + Open original export + + src/app/features/organization/features/maintenance-exports/ui/pages/maintenance-exports-page/maintenance-exports-page.component.html + 104,107 + + + + Retained adjustment reason + + src/app/features/organization/features/maintenance-exports/ui/pages/maintenance-exports-page/maintenance-exports-page.component.html + 112,114 + + + + External import confirmed + + src/app/features/organization/features/maintenance-exports/ui/pages/maintenance-exports-page/maintenance-exports-page.component.html + 120,122 + + + + The file has been generated. No external import has been confirmed. + + src/app/features/organization/features/maintenance-exports/ui/pages/maintenance-exports-page/maintenance-exports-page.component.html + 130,133 + + + + Private archive including internal costs. + + src/app/features/organization/features/maintenance-exports/ui/pages/maintenance-exports-page/maintenance-exports-page.component.html + 136,138 + + + + Internal costs are incomplete. Unknown amounts are retained explicitly. + + src/app/features/organization/features/maintenance-exports/ui/pages/maintenance-exports-page/maintenance-exports-page.component.html + 141,144 + + + + Download retained JSON + + src/app/features/organization/features/maintenance-exports/ui/pages/maintenance-exports-page/maintenance-exports-page.component.html + 174,176 + + + + Download retained CSV + + src/app/features/organization/features/maintenance-exports/ui/pages/maintenance-exports-page/maintenance-exports-page.component.html + 184,186 + + + + Append adjustment + + src/app/features/organization/features/maintenance-exports/ui/pages/maintenance-exports-page/maintenance-exports-page.component.html + 196,199 + + + + Confirm external import + + src/app/features/organization/features/maintenance-exports/ui/pages/maintenance-exports-page/maintenance-exports-page.component.html + 208,211 + + + + Retained archives + + src/app/features/organization/features/maintenance-exports/ui/pages/maintenance-exports-page/maintenance-exports-page.component.html + 216,219 + + + + No retained export yet + + src/app/features/organization/features/maintenance-exports/ui/pages/maintenance-exports-page/maintenance-exports-page.component.html + 240,241 + + + + Generate an export from ready published maintenance dossiers. + + src/app/features/organization/features/maintenance-exports/ui/pages/maintenance-exports-page/maintenance-exports-page.component.html + 242,244 + + + + System and version + + src/app/features/organization/features/maintenance-exports/ui/pages/maintenance-exports-page/maintenance-exports-page.component.html + 251,252 + + + + External import state + + src/app/features/organization/features/maintenance-exports/ui/pages/maintenance-exports-page/maintenance-exports-page.component.html + 252,253 + + + + Created + + src/app/features/organization/features/maintenance-exports/ui/pages/maintenance-exports-page/maintenance-exports-page.component.html + 253,254 + + + + Rows + + src/app/features/organization/features/maintenance-exports/ui/pages/maintenance-exports-page/maintenance-exports-page.component.html + 254,256 + + + + Private costs + + src/app/features/organization/features/maintenance-exports/ui/pages/maintenance-exports-page/maintenance-exports-page.component.html + 273,275 + + + + Page of + + src/app/features/organization/features/maintenance-exports/ui/pages/maintenance-exports-page/maintenance-exports-page.component.html + 302,303 + + + + Current external references + + src/app/features/organization/features/maintenance-exports/ui/pages/maintenance-exports-page/maintenance-exports-page.component.html + 319,321 + + + + Additional mappings are available through the scoped resource selector. + + src/app/features/organization/features/maintenance-exports/ui/pages/maintenance-exports-page/maintenance-exports-page.component.html + 341,345 + + + + Operations retain their identity across retries. Review a confirmed conflict before submitting a new command. + + src/app/features/organization/features/maintenance-exports/ui/pages/maintenance-exports-page/maintenance-exports-page.component.html + 360,364 + + + + The response is uncertain. Recover this same operation before changing the draft. + + src/app/features/organization/features/maintenance-exports/ui/pages/maintenance-exports-page/maintenance-exports-page.component.html + 368,371 + + + + Recover the same operation + + src/app/features/organization/features/maintenance-exports/ui/pages/maintenance-exports-page/maintenance-exports-page.component.html + 379,381 + + + + The source changed or conflicts with this command. Your draft is retained. + + src/app/features/organization/features/maintenance-exports/ui/pages/maintenance-exports-page/maintenance-exports-page.component.html + 386,388 + + + + Load latest source + + src/app/features/organization/features/maintenance-exports/ui/pages/maintenance-exports-page/maintenance-exports-page.component.html + 398,400 + + + + Use reviewed revision and keep my draft + + src/app/features/organization/features/maintenance-exports/ui/pages/maintenance-exports-page/maintenance-exports-page.component.html + 408,410 + + + + Discard the local draft and close this sheet? + + src/app/features/organization/features/maintenance-exports/ui/pages/maintenance-exports-page/maintenance-exports-page.component.html + 456,458 + + + + Keep editing + + src/app/features/organization/features/maintenance-exports/ui/pages/maintenance-exports-page/maintenance-exports-page.component.html + 467,469 + + + + Discard draft + + src/app/features/organization/features/maintenance-exports/ui/pages/maintenance-exports-page/maintenance-exports-page.component.html + 476,478 + + + + Generate a prestation export + + src/app/features/organization/features/maintenance-exports/ui/pages/maintenance-exports-page/maintenance-exports-page.component.ts + 496 + + + + Append an adjustment + + src/app/features/organization/features/maintenance-exports/ui/pages/maintenance-exports-page/maintenance-exports-page.component.ts + 497 + + + + Confirm an external import + + src/app/features/organization/features/maintenance-exports/ui/pages/maintenance-exports-page/maintenance-exports-page.component.ts + 498 + + + + Map an external resource reference + + src/app/features/organization/features/maintenance-exports/ui/pages/maintenance-exports-page/maintenance-exports-page.component.ts + 499 + + + + Export generated + + src/app/features/organization/features/maintenance-exports/ui/pages/maintenance-exports-page/maintenance-exports-page.component.ts + 660 + + + + Import confirmed + + src/app/features/organization/features/maintenance-exports/ui/pages/maintenance-exports-page/maintenance-exports-page.component.ts + 661 + + + + Initial export + + src/app/features/organization/features/maintenance-exports/ui/pages/maintenance-exports-page/maintenance-exports-page.component.ts + 682 + + + + Adjustment + + src/app/features/organization/features/maintenance-exports/ui/pages/maintenance-exports-page/maintenance-exports-page.component.ts + 683 + + + + Operation plans + + src/app/features/organization/features/maintenance-schedules/maintenance-schedules.routes.ts + 40 + + + + Maintenance + + src/app/features/organization/features/maintenance-schedules/maintenance-schedules.routes.ts + 51 + + + src/app/features/organization/navigation/organization-navigation.config.ts + 508 + + + + Every 4 weeks + + src/app/features/organization/features/maintenance-schedules/options/maintenance-override-duration-options.constants.ts + 24 + + + + Every month + + src/app/features/organization/features/maintenance-schedules/options/maintenance-override-duration-options.constants.ts + 25 + + + + Every 3 months + + src/app/features/organization/features/maintenance-schedules/options/maintenance-override-duration-options.constants.ts + 26 + + + + Every 6 months + + src/app/features/organization/features/maintenance-schedules/options/maintenance-override-duration-options.constants.ts + 27 + + + + Every year + + src/app/features/organization/features/maintenance-schedules/options/maintenance-override-duration-options.constants.ts + 28 + + + + Every 2 years + + src/app/features/organization/features/maintenance-schedules/options/maintenance-override-duration-options.constants.ts + 29 + + + + Every 5 years + + src/app/features/organization/features/maintenance-schedules/options/maintenance-override-duration-options.constants.ts + 30 + + + + Every 10 years + + src/app/features/organization/features/maintenance-schedules/options/maintenance-override-duration-options.constants.ts + 31 + + + + Campaign # created with work item(s) + + src/app/features/organization/features/maintenance-schedules/state/maintenance-schedules/maintenance-schedules.store.ts + 382 + + + + Prepared + + src/app/features/organization/features/maintenance-schedules/ui/components/maintenance-plan-list/maintenance-plan-list.component.html + 17,20 + + + + View equipment + + src/app/features/organization/features/maintenance-schedules/ui/components/maintenance-plan-list/maintenance-plan-list.component.html + 25,27 + + + + Next due: + + src/app/features/organization/features/maintenance-schedules/ui/components/maintenance-plan-list/maintenance-plan-list.component.html + 30,32 + + + + First deadline to configure — no validated control recorded. + + src/app/features/organization/features/maintenance-schedules/ui/components/maintenance-plan-list/maintenance-plan-list.component.html + 35,38 + + + + Original deadline: · attempt + + src/app/features/organization/features/maintenance-schedules/ui/components/maintenance-plan-list/maintenance-plan-list.component.html + 40,47 + + + + Open existing intervention + + src/app/features/organization/features/maintenance-schedules/ui/components/maintenance-plan-list/maintenance-plan-list.component.html + 53,56 + + + + Preview dates + + src/app/features/organization/features/maintenance-schedules/ui/components/maintenance-plan-list/maintenance-plan-list.component.html + 68,70 + + + + Configure + + src/app/features/organization/features/maintenance-schedules/ui/components/maintenance-plan-list/maintenance-plan-list.component.html + 80,83 + + + + Prepare intervention + + src/app/features/organization/features/maintenance-schedules/ui/components/maintenance-plan-list/maintenance-plan-list.component.html + 93,95 + + + + Start a new attempt + + src/app/features/organization/features/maintenance-schedules/ui/components/maintenance-plan-list/maintenance-plan-list.component.html + 105,107 + + + + Control + + src/app/features/organization/features/maintenance-schedules/ui/components/maintenance-plan-list/maintenance-plan-list.component.ts + 249 + + + src/app/features/organization/features/maintenance-schedules/ui/forms/maintenance-plan-form/maintenance-plan-form.component.ts + 361 + + + + Maintenance + + src/app/features/organization/features/maintenance-schedules/ui/components/maintenance-plan-list/maintenance-plan-list.component.ts + 250 + + + src/app/features/organization/features/maintenance-schedules/ui/forms/maintenance-plan-form/maintenance-plan-form.component.ts + 362 + + + src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-plans-page/maintenance-plans-page.component.html + 205,207 + + + + Historical cadence retained + + src/app/features/organization/features/maintenance-schedules/ui/components/maintenance-plan-list/maintenance-plan-list.component.ts + 269 + + + + Anchored calendar + + src/app/features/organization/features/maintenance-schedules/ui/components/maintenance-plan-list/maintenance-plan-list.component.ts + 270 + + + + Generate inspection campaign + + src/app/features/organization/features/maintenance-schedules/ui/dialogs/maintenance-campaign-dialog/maintenance-campaign-dialog.component.html + 14,15 + + + + Creates an intervention covering every schedule currently due, optionally narrowed to one facility or equipment type. + + src/app/features/organization/features/maintenance-schedules/ui/dialogs/maintenance-campaign-dialog/maintenance-campaign-dialog.component.html + 16,21 + + + + Interval override + + src/app/features/organization/features/maintenance-schedules/ui/dialogs/maintenance-override-dialog/maintenance-override-dialog.component.html + 12,13 + + + + Override how often this equipment type is inspected, or follow the organization default. + + src/app/features/organization/features/maintenance-schedules/ui/dialogs/maintenance-override-dialog/maintenance-override-dialog.component.html + 14,18 + + + + Interval + + src/app/features/organization/features/maintenance-schedules/ui/dialogs/maintenance-override-dialog/maintenance-override-dialog.component.html + 25,28 + + + + Use organization default + + src/app/features/organization/features/maintenance-schedules/ui/dialogs/maintenance-override-dialog/maintenance-override-dialog.component.html + 45,47 + + + src/app/features/organization/features/maintenance-schedules/ui/dialogs/maintenance-override-dialog/maintenance-override-dialog.component.ts + 169 + + + + Saving… + + src/app/features/organization/features/maintenance-schedules/ui/dialogs/maintenance-override-dialog/maintenance-override-dialog.component.html + 77,79 + + + + Save + + src/app/features/organization/features/maintenance-schedules/ui/dialogs/maintenance-override-dialog/maintenance-override-dialog.component.html + 79,82 + + + + Unknown value + + src/app/features/organization/features/maintenance-schedules/ui/dialogs/maintenance-override-dialog/maintenance-override-dialog.component.ts + 174 + + + + Name + + src/app/features/organization/features/maintenance-schedules/ui/forms/maintenance-campaign-form/maintenance-campaign-form.component.html + 11,13 + + + + Q1 fire extinguisher round + + src/app/features/organization/features/maintenance-schedules/ui/forms/maintenance-campaign-form/maintenance-campaign-form.component.html + 19,20 + + + + Due before + + src/app/features/organization/features/maintenance-schedules/ui/forms/maintenance-campaign-form/maintenance-campaign-form.component.html + 32,34 + + + + Facility + + src/app/features/organization/features/maintenance-schedules/ui/forms/maintenance-campaign-form/maintenance-campaign-form.component.html + 56,59 + + + + Equipment type + + src/app/features/organization/features/maintenance-schedules/ui/forms/maintenance-campaign-form/maintenance-campaign-form.component.html + 75,77 + + + + Every type + + src/app/features/organization/features/maintenance-schedules/ui/forms/maintenance-campaign-form/maintenance-campaign-form.component.html + 84,85 + + + src/app/features/organization/features/maintenance-schedules/ui/forms/maintenance-campaign-form/maintenance-campaign-form.component.html + 91,93 + + + src/app/features/organization/features/maintenance-schedules/ui/forms/maintenance-campaign-form/maintenance-campaign-form.component.ts + 366 + + + + Generating… + + src/app/features/organization/features/maintenance-schedules/ui/forms/maintenance-campaign-form/maintenance-campaign-form.component.html + 125,127 + + + + Generate campaign + + src/app/features/organization/features/maintenance-schedules/ui/forms/maintenance-campaign-form/maintenance-campaign-form.component.html + 127,131 + + + + Name is required. + + src/app/features/organization/features/maintenance-schedules/ui/forms/maintenance-campaign-form/maintenance-campaign-form.component.ts + 307 + + + + Enter at least 2 characters. + + src/app/features/organization/features/maintenance-schedules/ui/forms/maintenance-campaign-form/maintenance-campaign-form.component.ts + 310 + + + + This name is too long. + + src/app/features/organization/features/maintenance-schedules/ui/forms/maintenance-campaign-form/maintenance-campaign-form.component.ts + 313 + + + + A due-before date is required. + + src/app/features/organization/features/maintenance-schedules/ui/forms/maintenance-campaign-form/maintenance-campaign-form.component.ts + 316 + + + + The campaign could not be generated. + + src/app/features/organization/features/maintenance-schedules/ui/forms/maintenance-campaign-form/maintenance-campaign-form.component.ts + 340 + + + + Equipment + + src/app/features/organization/features/maintenance-schedules/ui/forms/maintenance-plan-form/maintenance-plan-form.component.html + 19,22 + + + + No matching equipment loaded. + + src/app/features/organization/features/maintenance-schedules/ui/forms/maintenance-plan-form/maintenance-plan-form.component.html + 33,35 + + + + Loading equipment… + + src/app/features/organization/features/maintenance-schedules/ui/forms/maintenance-plan-form/maintenance-plan-form.component.html + 46,49 + + + + Previous equipment + + src/app/features/organization/features/maintenance-schedules/ui/forms/maintenance-plan-form/maintenance-plan-form.component.html + 71,73 + + + + Next equipment + + src/app/features/organization/features/maintenance-schedules/ui/forms/maintenance-plan-form/maintenance-plan-form.component.html + 81,83 + + + + Operation name + + src/app/features/organization/features/maintenance-schedules/ui/forms/maintenance-plan-form/maintenance-plan-form.component.html + 92,95 + + + + Operation + + src/app/features/organization/features/maintenance-schedules/ui/forms/maintenance-plan-form/maintenance-plan-form.component.html + 108,110 + + + + The calendar is locked while an occurrence is open. You can still change the operation name. + + src/app/features/organization/features/maintenance-schedules/ui/forms/maintenance-plan-form/maintenance-plan-form.component.html + 130,133 + + + + Every + + src/app/features/organization/features/maintenance-schedules/ui/forms/maintenance-plan-form/maintenance-plan-form.component.html + 136,139 + + + + Calendar unit + + src/app/features/organization/features/maintenance-schedules/ui/forms/maintenance-plan-form/maintenance-plan-form.component.html + 152,154 + + + + Choose a unit + + src/app/features/organization/features/maintenance-schedules/ui/forms/maintenance-plan-form/maintenance-plan-form.component.html + 157,158 + + + + Anchor date + + src/app/features/organization/features/maintenance-schedules/ui/forms/maintenance-plan-form/maintenance-plan-form.component.html + 176,179 + + + + First due date + + src/app/features/organization/features/maintenance-schedules/ui/forms/maintenance-plan-form/maintenance-plan-form.component.html + 194,197 + + + + Optional for a new plan; defaults to the anchor date. Historical equipment without a recorded control needs an explicit first deadline. + + src/app/features/organization/features/maintenance-schedules/ui/forms/maintenance-plan-form/maintenance-plan-form.component.html + 204,207 + + + + Choose your organization's frequency. A monthly date uses the last day of shorter months and retains its original anchor. This configuration does not certify regulatory compliance. + + src/app/features/organization/features/maintenance-schedules/ui/forms/maintenance-plan-form/maintenance-plan-form.component.html + 210,213 + + + + Save and preview dates + + src/app/features/organization/features/maintenance-schedules/ui/forms/maintenance-plan-form/maintenance-plan-form.component.html + 236,239 + + + + Select an equipment. + + src/app/features/organization/features/maintenance-schedules/ui/forms/maintenance-plan-form/maintenance-plan-form.component.ts + 286 + + + + Enter an operation name. + + src/app/features/organization/features/maintenance-schedules/ui/forms/maintenance-plan-form/maintenance-plan-form.component.ts + 289 + + + + Use at most 160 characters. + + src/app/features/organization/features/maintenance-schedules/ui/forms/maintenance-plan-form/maintenance-plan-form.component.ts + 292 + + + + Choose a calendar unit. + + src/app/features/organization/features/maintenance-schedules/ui/forms/maintenance-plan-form/maintenance-plan-form.component.ts + 295 + + + + Enter a positive interval. + + src/app/features/organization/features/maintenance-schedules/ui/forms/maintenance-plan-form/maintenance-plan-form.component.ts + 298 + + + + Enter a whole number. + + src/app/features/organization/features/maintenance-schedules/ui/forms/maintenance-plan-form/maintenance-plan-form.component.ts + 305 + + + + Choose an anchor date. + + src/app/features/organization/features/maintenance-schedules/ui/forms/maintenance-plan-form/maintenance-plan-form.component.ts + 309 + + + + Days + + src/app/features/organization/features/maintenance-schedules/ui/forms/maintenance-plan-form/maintenance-plan-form.component.ts + 342 + + + + Weeks + + src/app/features/organization/features/maintenance-schedules/ui/forms/maintenance-plan-form/maintenance-plan-form.component.ts + 343 + + + + Months + + src/app/features/organization/features/maintenance-schedules/ui/forms/maintenance-plan-form/maintenance-plan-form.component.ts + 344 + + + + Years + + src/app/features/organization/features/maintenance-schedules/ui/forms/maintenance-plan-form/maintenance-plan-form.component.ts + 345 + + + + The plan could not be saved. + + src/app/features/organization/features/maintenance-schedules/ui/forms/maintenance-plan-form/maintenance-plan-form.component.ts + 381 + + + + Historical schedules + + src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-plans-page/maintenance-plans-page.component.html + 8,12 + + + + Prepare an operation plan + + src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-plans-page/maintenance-plans-page.component.html + 19,23 + + + + Keep controls and maintenance independent. Preparing work never marks an operation as completed. + + src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-plans-page/maintenance-plans-page.component.html + 24,26 + + + + Scheduling authority unavailable + + src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-plans-page/maintenance-plans-page.component.html + 28,29 + + + + Historical scheduling remains active + + src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-plans-page/maintenance-plans-page.component.html + 43,45 + + + + Prepare the historical plans and review their dates before switching. Existing intervals and recorded history are retained. Only one scheduling engine generates work. + + src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-plans-page/maintenance-plans-page.component.html + 46,49 + + + + Prepare historical plans + + src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-plans-page/maintenance-plans-page.component.html + 60,62 + + + + Review scheduling switch + + src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-plans-page/maintenance-plans-page.component.html + 71,73 + + + + Operation configuration + + src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-plans-page/maintenance-plans-page.component.html + 83,85 + + + + Close preview + + src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-plans-page/maintenance-plans-page.component.html + 115,118 + + + + The server calculates these next dates. Review them before activating this operation. + + src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-plans-page/maintenance-plans-page.component.html + 119,121 + + + + Next three deadlines + + src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-plans-page/maintenance-plans-page.component.html + 135,136 + + + + Configure the first deadline to preview this historical operation. + + src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-plans-page/maintenance-plans-page.component.html + 144,147 + + + + Activate this operation + + src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-plans-page/maintenance-plans-page.component.html + 158,160 + + + + Pause this operation + + src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-plans-page/maintenance-plans-page.component.html + 169,171 + + + + Intervention could not be prepared + + src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-plans-page/maintenance-plans-page.component.html + 183,185 + + + + Open intervention # + + src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-plans-page/maintenance-plans-page.component.html + 194,197 + + + + Operation deadlines + + src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-plans-page/maintenance-plans-page.component.html + 200,201 + + + + Controls + + src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-plans-page/maintenance-plans-page.component.html + 203,204 + + + + Search operation plans + + src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-plans-page/maintenance-plans-page.component.html + 213,214 + + + src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-plans-page/maintenance-plans-page.component.html + 215,216 + + + + Operation plans could not be loaded + + src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-plans-page/maintenance-plans-page.component.html + 223,225 + + + + No operation plans match this view + + src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-plans-page/maintenance-plans-page.component.html + 242,244 + + + + Adjust your search or prepare an equipment operation. + + src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-plans-page/maintenance-plans-page.component.html + 245,247 + + + + Activate operation plans for this organization? + + src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-plans-page/maintenance-plans-page.component.html + 283,285 + + + + This switches work generation from historical schedules to the prepared operation plans. The server checks existing campaigns and blocks ambiguous links. Historical intervals and results remain available. + + src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-plans-page/maintenance-plans-page.component.html + 286,289 + + + + Activate operation plans + + src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-plans-page/maintenance-plans-page.component.html + 308,309 + + + + Operation plans + + src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-schedules-page/maintenance-schedules-page.component.html + 8,11 + + + + Equipment types could not be loaded + + src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-schedules-page/maintenance-schedules-page.component.html + 13,14 + + + + Search schedules... + + src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-schedules-page/maintenance-schedules-page.component.html + 30,31 + + + src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-schedules-page/maintenance-schedules-page.component.html + 32,33 + + + + Filters + + src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-schedules-page/maintenance-schedules-page.component.html + 43 + + + + Export + + src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-schedules-page/maintenance-schedules-page.component.html + 65,67 + + + + Preparing the export… + + src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-schedules-page/maintenance-schedules-page.component.html + 74,78 + + + + Any status + + src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-schedules-page/maintenance-schedules-page.component.html + 97,98 + + + + No status matches. + + src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-schedules-page/maintenance-schedules-page.component.html + 99,100 + + + + Due status + + src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-schedules-page/maintenance-schedules-page.component.html + 101,102 + + + src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-schedules-page/maintenance-schedules-page.component.ts + 741 + + + + Any type + + src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-schedules-page/maintenance-schedules-page.component.html + 142,143 + + + + Search a type + + src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-schedules-page/maintenance-schedules-page.component.html + 144,145 + + + + No type matches. + + src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-schedules-page/maintenance-schedules-page.component.html + 146,147 + + + + Equipment type + + src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-schedules-page/maintenance-schedules-page.component.html + 148,149 + + + src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-schedules-page/maintenance-schedules-page.component.ts + 753 + + + + Pick a date + + src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-schedules-page/maintenance-schedules-page.component.html + 161,162 + + + + Due before + + src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-schedules-page/maintenance-schedules-page.component.html + 163,164 + + + src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-schedules-page/maintenance-schedules-page.component.ts + 759 + + + + Not available with your permissions + + src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-schedules-page/maintenance-schedules-page.component.html + 178,180 + + + + Ask an organization admin to grant you read access to maintenance schedules. + + src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-schedules-page/maintenance-schedules-page.component.html + 181,184 + + + + Couldn't load maintenance schedules + + src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-schedules-page/maintenance-schedules-page.component.html + 192,194 + + + + The schedules could not be loaded. Check your connection and try again. + + src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-schedules-page/maintenance-schedules-page.component.html + 195,198 + + + + Retry + + src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-schedules-page/maintenance-schedules-page.component.html + 200,202 + + + + No schedules match these filters + + src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-schedules-page/maintenance-schedules-page.component.html + 209,211 + + + + Adjust your search or filters and try again. + + src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-schedules-page/maintenance-schedules-page.component.html + 212,215 + + + + No maintenance schedules yet + + src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-schedules-page/maintenance-schedules-page.component.html + 225,226 + + + + Track equipment to see its maintenance schedule here. + + src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-schedules-page/maintenance-schedules-page.component.html + 227,230 + + + + Go to equipment + + src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-schedules-page/maintenance-schedules-page.component.html + 233,237 + + + + Generate inspection campaign + + src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-schedules-page/maintenance-schedules-page.component.html + 302,305 + + + src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-schedules-page/maintenance-schedules-page.component.html + 307,312 + + + + Facility + + src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-schedules-page/maintenance-schedules-page.component.ts + 747 + + + + before + + src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-schedules-page/maintenance-schedules-page.component.ts + 763 + + + + Couldn't export maintenance schedules. + + src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-schedules-page/maintenance-schedules-page.component.ts + 1401 + + + + Maintenance schedules + + src/app/features/organization/features/maintenance-schedules/ui/tables/maintenance-schedule-table/maintenance-schedule-table.component.html + 2,3 + + + + Equipment + + src/app/features/organization/features/maintenance-schedules/ui/tables/maintenance-schedule-table/maintenance-schedule-table.component.html + 12,13 + + + + Facility + + src/app/features/organization/features/maintenance-schedules/ui/tables/maintenance-schedule-table/maintenance-schedule-table.component.html + 13,14 + + + + Last inspected + + src/app/features/organization/features/maintenance-schedules/ui/tables/maintenance-schedule-table/maintenance-schedule-table.component.html + 14,15 + + + src/app/features/organization/features/maintenance-schedules/ui/tables/maintenance-schedule-table/maintenance-schedule-table.component.html + 124,125 + + + + Next due + + src/app/features/organization/features/maintenance-schedules/ui/tables/maintenance-schedule-table/maintenance-schedule-table.component.html + 15,16 + + + + Status + + src/app/features/organization/features/maintenance-schedules/ui/tables/maintenance-schedule-table/maintenance-schedule-table.component.html + 16,17 + + + + Interval override + + src/app/features/organization/features/maintenance-schedules/ui/tables/maintenance-schedule-table/maintenance-schedule-table.component.html + 17,19 + + + src/app/features/organization/features/maintenance-schedules/ui/tables/maintenance-schedule-table/maintenance-schedule-table.component.html + 130,131 + + + + Organization default + + src/app/features/organization/features/maintenance-schedules/ui/tables/maintenance-schedule-table/maintenance-schedule-table.component.html + 72,77 + + + src/app/features/organization/features/maintenance-schedules/ui/tables/maintenance-schedule-table/maintenance-schedule-table.component.html + 134,138 + + + + No results. + + src/app/features/organization/features/maintenance-schedules/ui/tables/maintenance-schedule-table/maintenance-schedule-table.component.html + 149,153 + + + + View facility + + src/app/features/organization/features/maintenance-schedules/ui/tables/maintenance-schedule-table/maintenance-schedule-table.component.html + 162,166 + + + + Unassigned + + src/app/features/organization/features/maintenance-schedules/ui/tables/maintenance-schedule-table/maintenance-schedule-table.component.html + 166,170 + + + + Never inspected + + src/app/features/organization/features/maintenance-schedules/ui/tables/maintenance-schedule-table/maintenance-schedule-table.component.html + 175,177 + + + + Edit interval override + + src/app/features/organization/features/maintenance-schedules/ui/tables/maintenance-schedule-table/maintenance-schedule-table.component.html + 190,191 + + + src/app/features/organization/features/maintenance-schedules/ui/tables/maintenance-schedule-table/maintenance-schedule-table.component.html + 192,193 + + + + Evaluated + + src/app/features/organization/features/maintenance-schedules/ui/tables/maintenance-schedule-table/maintenance-schedule-table.component.html + 205,206 + + + + Not yet evaluated + + src/app/features/organization/features/maintenance-schedules/ui/tables/maintenance-schedule-table/maintenance-schedule-table.component.html + 208,212 + + + src/app/features/organization/features/maintenance-schedules/ui/tables/maintenance-schedule-table/maintenance-schedule-table.component.ts + 480 + + + + at + + src/app/features/organization/features/maintenance-schedules/ui/tables/maintenance-schedule-table/maintenance-schedule-table.component.ts + 352 + + + + Evaluated + + src/app/features/organization/features/maintenance-schedules/ui/tables/maintenance-schedule-table/maintenance-schedule-table.component.ts + 479 + + + + Reminder sent + + src/app/features/organization/features/maintenance-schedules/ui/tables/maintenance-schedule-table/maintenance-schedule-table.component.ts + 506 + + + + Draft + + src/app/features/organization/features/procurement/models/procurement-tag/procurement-tag.util.ts + 10 + + + + Ordered + + src/app/features/organization/features/procurement/models/procurement-tag/procurement-tag.util.ts + 11 + + + + Partially received + + src/app/features/organization/features/procurement/models/procurement-tag/procurement-tag.util.ts + 12 + + + + Received + + src/app/features/organization/features/procurement/models/procurement-tag/procurement-tag.util.ts + 13 + + + + Remaining quantities cancelled + + src/app/features/organization/features/procurement/models/procurement-tag/procurement-tag.util.ts + 14 + + + + Awaiting reserve equipment + + src/app/features/organization/features/procurement/models/procurement-tag/procurement-tag.util.ts + 25 + + + + Reserve equipment created + + src/app/features/organization/features/procurement/models/procurement-tag/procurement-tag.util.ts + 26 + + + + Stock received + + src/app/features/organization/features/procurement/models/procurement-tag/procurement-tag.util.ts + 27 + + + + Returned + + src/app/features/organization/features/procurement/models/procurement-tag/procurement-tag.util.ts + 28 + + + + Awaiting inventory reconciliation + + src/app/features/organization/features/procurement/models/procurement-tag/procurement-tag.util.ts + 54 + + + + Inventory reversal confirmed + + src/app/features/organization/features/procurement/models/procurement-tag/procurement-tag.util.ts + 55 + + + + Purchasing and receipts + + src/app/features/organization/features/procurement/procurement.routes.ts + 25 + + + src/app/features/organization/navigation/organization-navigation.config.ts + 452 + + + + Still awaiting delivery: . This receipt can contain up to . + + src/app/features/organization/features/procurement/ui/forms/procurement-receipt-form/procurement-receipt-form.component.html + 8,11 + + + + Quantity + + src/app/features/organization/features/procurement/ui/forms/procurement-receipt-form/procurement-receipt-form.component.html + 14,17 + + + src/app/features/organization/features/procurement/ui/forms/procurement-return-form/procurement-return-form.component.html + 13,16 + + + src/app/features/organization/features/procurement/ui/forms/purchase-order-form/purchase-order-form.component.html + 213,215 + + + + Receiving warehouse + + src/app/features/organization/features/procurement/ui/forms/procurement-receipt-form/procurement-receipt-form.component.html + 35,36 + + + + Materials remain in this receipt until you explicitly create their reserve equipment identities. No quantitative stock is added. + + src/app/features/organization/features/procurement/ui/forms/procurement-receipt-form/procurement-receipt-form.component.html + 49,54 + + + + Physical receipt date and time + + src/app/features/organization/features/procurement/ui/forms/procurement-receipt-form/procurement-receipt-form.component.html + 55,58 + + + + Use the local date and time of your device. The recorded instant retains its timezone. + + src/app/features/organization/features/procurement/ui/forms/procurement-receipt-form/procurement-receipt-form.component.html + 64,66 + + + + UTC offset for this repeated hour + + src/app/features/organization/features/procurement/ui/forms/procurement-receipt-form/procurement-receipt-form.component.html + 75,77 + + + + Choose the actual UTC offset + + src/app/features/organization/features/procurement/ui/forms/procurement-receipt-form/procurement-receipt-form.component.html + 82,83 + + + + The same local time occurred twice because the clocks changed. Select the offset shown on the physical receipt record. + + src/app/features/organization/features/procurement/ui/forms/procurement-receipt-form/procurement-receipt-form.component.html + 96,99 + + + + Use a decimal point and at most six decimal places. Quantities and amounts remain exact. + + src/app/features/organization/features/procurement/ui/forms/procurement-receipt-form/procurement-receipt-form.component.html + 108,110 + + + src/app/features/organization/features/procurement/ui/forms/purchase-order-form/purchase-order-form.component.html + 283,285 + + + + Receipt could not be confirmed + + src/app/features/organization/features/procurement/ui/forms/procurement-receipt-form/procurement-receipt-form.component.html + 112,113 + + + + Record physical receipt + + src/app/features/organization/features/procurement/ui/forms/procurement-receipt-form/procurement-receipt-form.component.html + 131,135 + + + + Enter a positive quantity within the remaining delivery. Equipment needs whole units, up to 100 per receipt. + + src/app/features/organization/features/procurement/ui/forms/procurement-receipt-form/procurement-receipt-form.component.ts + 261 + + + + Choose the receiving warehouse. + + src/app/features/organization/features/procurement/ui/forms/procurement-receipt-form/procurement-receipt-form.component.ts + 266 + + + + Enter the physical receipt date and time. + + src/app/features/organization/features/procurement/ui/forms/procurement-receipt-form/procurement-receipt-form.component.ts + 269 + + + + Choose a valid receipt date and time that is not in the future. + + src/app/features/organization/features/procurement/ui/forms/procurement-receipt-form/procurement-receipt-form.component.ts + 280 + + + + This local hour occurs twice. Choose the UTC offset of the actual receipt. + + src/app/features/organization/features/procurement/ui/forms/procurement-receipt-form/procurement-receipt-form.component.ts + 288 + + + + Received units not already returned: . + + src/app/features/organization/features/procurement/ui/forms/procurement-return-form/procurement-return-form.component.html + 8,10 + + + + Physical return reason + + src/app/features/organization/features/procurement/ui/forms/procurement-return-form/procurement-return-form.component.html + 30,32 + + + + The return remains linked to its original receipt. If stock needs reconciliation, the physical declaration is retained until the movement can be confirmed. + + src/app/features/organization/features/procurement/ui/forms/procurement-return-form/procurement-return-form.component.html + 45,48 + + + + Return could not be confirmed + + src/app/features/organization/features/procurement/ui/forms/procurement-return-form/procurement-return-form.component.html + 50,51 + + + + Record physical return + + src/app/features/organization/features/procurement/ui/forms/procurement-return-form/procurement-return-form.component.html + 69,73 + + + + Enter a positive quantity within the received units not already returned. + + src/app/features/organization/features/procurement/ui/forms/procurement-return-form/procurement-return-form.component.ts + 202 + + + + Explain why the goods were returned. + + src/app/features/organization/features/procurement/ui/forms/procurement-return-form/procurement-return-form.component.ts + 206 + + + src/app/features/organization/features/procurement/ui/forms/procurement-return-form/procurement-return-form.component.ts + 213 + + + + Purchase draft + + src/app/features/organization/features/procurement/ui/forms/purchase-order-form/purchase-order-form.component.html + 8,10 + + + + Order name + + src/app/features/organization/features/procurement/ui/forms/purchase-order-form/purchase-order-form.component.html + 12,14 + + + + Internal supplier + + src/app/features/organization/features/procurement/ui/forms/purchase-order-form/purchase-order-form.component.html + 27,30 + + + + No active matching supplier loaded. + + src/app/features/organization/features/procurement/ui/forms/purchase-order-form/purchase-order-form.component.html + 38,40 + + + + Loading suppliers… + + src/app/features/organization/features/procurement/ui/forms/purchase-order-form/purchase-order-form.component.html + 55,57 + + + + Previous suppliers + + src/app/features/organization/features/procurement/ui/forms/purchase-order-form/purchase-order-form.component.html + 79,81 + + + + Next suppliers + + src/app/features/organization/features/procurement/ui/forms/purchase-order-form/purchase-order-form.component.html + 88,90 + + + + Order line + + src/app/features/organization/features/procurement/ui/forms/purchase-order-form/purchase-order-form.component.html + 97,98 + + + + Item kind + + src/app/features/organization/features/procurement/ui/forms/purchase-order-form/purchase-order-form.component.html + 104,106 + + + + Stock article + + src/app/features/organization/features/procurement/ui/forms/purchase-order-form/purchase-order-form.component.html + 126,127 + + + + Equipment type + + src/app/features/organization/features/procurement/ui/forms/purchase-order-form/purchase-order-form.component.html + 144,145 + + + + Choose a catalogue type + + src/app/features/organization/features/procurement/ui/forms/purchase-order-form/purchase-order-form.component.html + 150,151 + + + + Equipment name + + src/app/features/organization/features/procurement/ui/forms/purchase-order-form/purchase-order-form.component.html + 171,173 + + + + Brand + + src/app/features/organization/features/procurement/ui/forms/purchase-order-form/purchase-order-form.component.html + 184,186 + + + + Model + + src/app/features/organization/features/procurement/ui/forms/purchase-order-form/purchase-order-form.component.html + 195,197 + + + + Received materials await explicit creation as reserve equipment. They are not quantitative stock. + + src/app/features/organization/features/procurement/ui/forms/purchase-order-form/purchase-order-form.component.html + 204,207 + + + + Internal unit cost + + src/app/features/organization/features/procurement/ui/forms/purchase-order-form/purchase-order-form.component.html + 234,236 + + + + Organization currency. Leave empty when unknown. No commercial tax or billing is calculated here. + + src/app/features/organization/features/procurement/ui/forms/purchase-order-form/purchase-order-form.component.html + 244,246 + + + + Remove line + + src/app/features/organization/features/procurement/ui/forms/purchase-order-form/purchase-order-form.component.html + 263,266 + + + + Add a line + + src/app/features/organization/features/procurement/ui/forms/purchase-order-form/purchase-order-form.component.html + 280,282 + + + + Purchase draft could not be saved + + src/app/features/organization/features/procurement/ui/forms/purchase-order-form/purchase-order-form.component.html + 287,288 + + + + Save purchase draft + + src/app/features/organization/features/procurement/ui/forms/purchase-order-form/purchase-order-form.component.html + 306,309 + + + + Enter the order name. + + src/app/features/organization/features/procurement/ui/forms/purchase-order-form/purchase-order-form.component.ts + 378 + + + src/app/features/organization/features/procurement/ui/forms/purchase-order-form/purchase-order-form.component.ts + 385 + + + + Choose an internal supplier. + + src/app/features/organization/features/procurement/ui/forms/purchase-order-form/purchase-order-form.component.ts + 390 + + + + Add between 1 and 100 lines. + + src/app/features/organization/features/procurement/ui/forms/purchase-order-form/purchase-order-form.component.ts + 397 + + + + Choose a stock article. + + src/app/features/organization/features/procurement/ui/forms/purchase-order-form/purchase-order-form.component.ts + 403 + + + + Choose an equipment type. + + src/app/features/organization/features/procurement/ui/forms/purchase-order-form/purchase-order-form.component.ts + 407 + + + + Enter a positive quantity up to 100000 with at most six decimals. Equipment needs whole units. + + src/app/features/organization/features/procurement/ui/forms/purchase-order-form/purchase-order-form.component.ts + 414 + + + + Enter a nonnegative amount with at most six decimals. + + src/app/features/organization/features/procurement/ui/forms/purchase-order-form/purchase-order-form.component.ts + 422 + + + + Stock article + + src/app/features/organization/features/procurement/ui/forms/purchase-order-form/purchase-order-form.component.ts + 502 + + + + Equipment to individualize + + src/app/features/organization/features/procurement/ui/forms/purchase-order-form/purchase-order-form.component.ts + 505 + + + + Supplier information + + src/app/features/organization/features/procurement/ui/forms/supplier-form/supplier-form.component.html + 7,9 + + + + Name + + src/app/features/organization/features/procurement/ui/forms/supplier-form/supplier-form.component.html + 10,12 + + + src/app/features/organization/features/procurement/ui/forms/supplier-form/supplier-form.component.html + 79,81 + + + + Internal reference + + src/app/features/organization/features/procurement/ui/forms/supplier-form/supplier-form.component.html + 25,27 + + + + Email + + src/app/features/organization/features/procurement/ui/forms/supplier-form/supplier-form.component.html + 35,37 + + + src/app/features/organization/features/procurement/ui/forms/supplier-form/supplier-form.component.html + 112,115 + + + + Telephone + + src/app/features/organization/features/procurement/ui/forms/supplier-form/supplier-form.component.html + 51,54 + + + src/app/features/organization/features/procurement/ui/forms/supplier-form/supplier-form.component.html + 131,134 + + + + Contacts + + src/app/features/organization/features/procurement/ui/forms/supplier-form/supplier-form.component.html + 69,70 + + + + Contacts are internal records. They do not receive access to FireGuard. + + src/app/features/organization/features/procurement/ui/forms/supplier-form/supplier-form.component.html + 71,73 + + + + Contact + + src/app/features/organization/features/procurement/ui/forms/supplier-form/supplier-form.component.html + 75,77 + + + + Role + + src/app/features/organization/features/procurement/ui/forms/supplier-form/supplier-form.component.html + 94,96 + + + + Remove contact + + src/app/features/organization/features/procurement/ui/forms/supplier-form/supplier-form.component.html + 154,157 + + + + Add contact + + src/app/features/organization/features/procurement/ui/forms/supplier-form/supplier-form.component.html + 171,174 + + + + Supplier could not be saved + + src/app/features/organization/features/procurement/ui/forms/supplier-form/supplier-form.component.html + 176,177 + + + + Keep at most 50 contacts per supplier. + + src/app/features/organization/features/procurement/ui/forms/supplier-form/supplier-form.component.ts + 338 + + + + Enter the supplier name. + + src/app/features/organization/features/procurement/ui/forms/supplier-form/supplier-form.component.ts + 342 + + + src/app/features/organization/features/procurement/ui/forms/supplier-form/supplier-form.component.ts + 349 + + + + Use at most 160 characters. + + src/app/features/organization/features/procurement/ui/forms/supplier-form/supplier-form.component.ts + 353 + + + src/app/features/organization/features/procurement/ui/forms/supplier-form/supplier-form.component.ts + 380 + + + + Use at most 80 characters. + + src/app/features/organization/features/procurement/ui/forms/supplier-form/supplier-form.component.ts + 356 + + + src/app/features/organization/features/procurement/ui/forms/supplier-form/supplier-form.component.ts + 392 + + + + Enter a valid email address. + + src/app/features/organization/features/procurement/ui/forms/supplier-form/supplier-form.component.ts + 359 + + + src/app/features/organization/features/procurement/ui/forms/supplier-form/supplier-form.component.ts + 362 + + + src/app/features/organization/features/procurement/ui/forms/supplier-form/supplier-form.component.ts + 383 + + + src/app/features/organization/features/procurement/ui/forms/supplier-form/supplier-form.component.ts + 386 + + + + Use at most 40 characters. + + src/app/features/organization/features/procurement/ui/forms/supplier-form/supplier-form.component.ts + 365 + + + src/app/features/organization/features/procurement/ui/forms/supplier-form/supplier-form.component.ts + 389 + + + + Enter the contact name. + + src/app/features/organization/features/procurement/ui/forms/supplier-form/supplier-form.component.ts + 369 + + + src/app/features/organization/features/procurement/ui/forms/supplier-form/supplier-form.component.ts + 376 + + + + Save supplier + + src/app/features/organization/features/procurement/ui/forms/supplier-form/supplier-form.component.ts + 411 + + + + Create supplier + + src/app/features/organization/features/procurement/ui/forms/supplier-form/supplier-form.component.ts + 412 + + + + Purchasing workspace + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.html + 5,6 + + + + Suppliers + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.html + 9,11 + + + + Orders + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.html + 12,14 + + + + Receipts + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.html + 15,17 + + + + Prepare purchases, record physical deliveries and follow reserve equipment or stock without losing the source history. + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.html + 20,23 + + + + Recover the original operation result + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.html + 26,28 + + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.html + 861,863 + + + + The response was lost. Retry the same declaration to recover its result before entering another physical operation. + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.html + 29,32 + + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.html + 864,867 + + + + Operation could not be confirmed + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.html + 47,48 + + + + Search suppliers + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.html + 67,68 + + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.html + 69,70 + + + + Supplier archive filter + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.html + 77,80 + + + + New internal supplier + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.html + 103,105 + + + + Suppliers could not be loaded + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.html + 111,113 + + + + No suppliers match this view + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.html + 137,138 + + + + Adjust your search or create an internal supplier. + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.html + 139,141 + + + + Archived + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.html + 156,159 + + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.html + 388,390 + + + + Edit supplier + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.html + 183,184 + + + + Archive supplier + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.html + 201,202 + + + + Order status filter + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.html + 231,232 + + + + New purchase draft + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.html + 252,254 + + + + Purchase orders could not be loaded + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.html + 260,262 + + + + No purchase orders match this view + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.html + 285,287 + + + + Choose another status or prepare an internal purchase. + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.html + 288,290 + + + + Created: + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.html + 318,319 + + + + View receipts + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.html + 332,333 + + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.html + 536,537 + + + + Source order could not be loaded + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.html + 354,356 + + + + Close order + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.html + 401,402 + + + + Edit purchase draft + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.html + 415,416 + + + + Place purchase order + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.html + 430,431 + + + + Cancel undelivered quantities + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.html + 449,451 + + + + Equipment types could not be loaded + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.html + 456,457 + + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.html + 929,931 + + + + Ordered: + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.html + 482,483 + + + + Physically received: + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.html + 484,486 + + + + Awaiting delivery: + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.html + 487,489 + + + + Physically returned: + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.html + 490,492 + + + + Internal unit cost: + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.html + 496,497 + + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.html + 626,628 + + + + Internal cost incomplete — unit cost unknown. + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.html + 500,502 + + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.html + 631,633 + + + + Record a delivery + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.html + 516,517 + + + + Physical receipt history + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.html + 546,548 + + + + Receipts could not be loaded + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.html + 552,553 + + + + No physical receipts recorded + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.html + 582,583 + + + + Record the actual delivery from its source line above. Placing an order does not receive its goods. + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.html + 585,586 + + + + Received: + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.html + 601,602 + + + + Physically returned: + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.html + 611,613 + + + + Return movement awaiting reconciliation: + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.html + 616,618 + + + + Open reserve equipment dossier + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.html + 645,647 + + + + Create reserve equipment + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.html + 660,661 + + + + Record a physical return + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.html + 673,674 + + + + View return history + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.html + 685,686 + + + + Physical returns and inventory reconciliation + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.html + 711,712 + + + + Each quantity and reason remains linked to its original receipt. Reconciliation confirms a stock movement and never rewrites the physical declaration. + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.html + 714,715 + + + + Return history could not be loaded + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.html + 721,722 + + + + No physical returns recorded + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.html + 751,752 + + + + Returned: + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.html + 770,771 + + + + Reconcile inventory movement + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.html + 796,797 + + + + Select the source purchase order + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.html + 826,828 + + + + Open an order's receipts to keep its physical deliveries and returns together. + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.html + 829,831 + + + + Keep the source record and the actual physical quantities together. + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.html + 854,857 + + + + Review the latest source revision + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.html + 884,886 + + + + Your typed draft is retained. Load the latest source before choosing to use its revision. + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.html + 887,890 + + + + Load latest source + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.html + 899,900 + + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.html + 1031,1033 + + + + Use reviewed revision and keep my draft + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.html + 908,910 + + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.html + 1040,1042 + + + + Confirm operation + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.html + 1055,1056 + + + + Active suppliers + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.ts + 819 + + + + Archived suppliers + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.ts + 820 + + + + All suppliers + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.ts + 821 + + + + All orders + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.ts + 836 + + + + Supplier information + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.ts + 885 + + + + Purchase draft + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.ts + 887 + + + + Record a physical delivery + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.ts + 889 + + + + Record a physical return + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.ts + 891 + + + + Procurement + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.ts + 893 + + + + Archive this supplier? + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.ts + 911 + + + + Place this purchase order? + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.ts + 913 + + + + Cancel undelivered quantities? + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.ts + 915 + + + + Create reserve equipment for this receipt? + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.ts + 917 + + + + Reconcile this physical return? + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.ts + 919 + + + + Confirm operation + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.ts + 921 + + + + The supplier remains in existing purchases and history. New orders require an active supplier. + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.ts + 939 + + + + The draft becomes an order. Physical deliveries will be recorded separately. + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.ts + 941 + + + + Only quantities still awaiting delivery are cancelled. Previous receipts and returns remain retained. + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.ts + 943 + + + + The server creates a bounded set of individually identified reserve equipment. A quota or unavailable type keeps the physical receipt awaiting action. + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.ts + 945 + + + + The server tries to confirm the inventory reversal. The original physical quantity and reason stay unchanged if stock is still insufficient. + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.ts + 947 + + + + Stock article label unavailable + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.ts + 1859 + + + + units + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.ts + 1886 + + + + unit unavailable + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.ts + 1889 + + + + Reserve equipment could not be created because the organization quota was reached. + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.ts + 1926 + + + + The equipment type is currently unavailable. The physical receipt is retained. + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.ts + 1927 + + + + The physical return is retained. Inventory needs reconciliation before its movement can be confirmed. + + src/app/features/organization/features/procurement/ui/pages/procurement-page/procurement-page.component.ts + 1928 + + + + Repair requests + + src/app/features/organization/features/service-requests/service-requests.routes.ts + 31 + + + src/app/features/organization/features/service-requests/service-requests.routes.ts + 33 + + + src/app/features/organization/navigation/organization-navigation.config.ts + 432 + + + + Maintenance request + + src/app/features/organization/features/service-requests/service-requests.routes.ts + 47 + + + src/app/features/organization/features/service-requests/service-requests.routes.ts + 49 + + + + This conversion could not be saved on this device. Your draft has been kept. Retry to send it. + + src/app/features/organization/features/service-requests/services/service-request-conversion/service-request-conversion.service.ts + 48 + + + + No equipment selected + + src/app/features/organization/features/service-requests/ui/components/service-request-equipment-picker/service-request-equipment-picker.component.html + 14,16 + + + src/app/features/organization/features/service-requests/ui/components/service-request-equipment-picker/service-request-equipment-picker.component.ts + 229 + + + + Unavailable for a new request + + src/app/features/organization/features/service-requests/ui/components/service-request-equipment-picker/service-request-equipment-picker.component.html + 25,28 + + + + Equipment selection requires permission to read the park. + + src/app/features/organization/features/service-requests/ui/components/service-request-equipment-picker/service-request-equipment-picker.component.html + 67,70 + + + + Choose equipment + + src/app/features/organization/features/service-requests/ui/components/service-request-equipment-picker/service-request-equipment-picker.component.ts + 214 + + + + Site selection requires permission to read sites. + + src/app/features/organization/features/service-requests/ui/components/service-request-site-picker/service-request-site-picker.component.html + 16,19 + + + + Equipment + + src/app/features/organization/features/service-requests/ui/forms/service-request-action-form/service-request-action-form.component.html + 13,15 + + + src/app/features/organization/features/service-requests/ui/forms/service-request-form/service-request-form.component.html + 28,30 + + + src/app/features/organization/features/service-requests/ui/pages/service-request-detail-page/service-request-detail-page.component.html + 159,161 + + + + Qualification note + + src/app/features/organization/features/service-requests/ui/forms/service-request-action-form/service-request-action-form.component.html + 33,36 + + + + Decision reason + + src/app/features/organization/features/service-requests/ui/forms/service-request-action-form/service-request-action-form.component.html + 54,57 + + + src/app/features/organization/features/service-requests/ui/pages/service-request-detail-page/service-request-detail-page.component.html + 233,234 + + + + Link an existing corrective task or create an intervention for this qualified request. + + src/app/features/organization/features/service-requests/ui/forms/service-request-action-form/service-request-action-form.component.html + 71,74 + + + + Corrective work + + src/app/features/organization/features/service-requests/ui/forms/service-request-action-form/service-request-action-form.component.html + 76,79 + + + + Create a corrective intervention + + src/app/features/organization/features/service-requests/ui/forms/service-request-action-form/service-request-action-form.component.html + 89,91 + + + src/app/features/organization/features/service-requests/ui/forms/service-request-action-form/service-request-action-form.component.ts + 382 + + + + Your permissions do not allow viewing existing interventions. + + src/app/features/organization/features/service-requests/ui/forms/service-request-action-form/service-request-action-form.component.html + 107,110 + + + + Loading open work + + src/app/features/organization/features/service-requests/ui/forms/service-request-action-form/service-request-action-form.component.html + 112,115 + + + + Use at most 10000 characters. + + src/app/features/organization/features/service-requests/ui/forms/service-request-action-form/service-request-action-form.component.ts + 338 + + + src/app/features/organization/features/service-requests/ui/forms/service-request-form/service-request-form.component.ts + 315 + + + + Use at most 2000 characters. + + src/app/features/organization/features/service-requests/ui/forms/service-request-action-form/service-request-action-form.component.ts + 341 + + + + Explain this decision. + + src/app/features/organization/features/service-requests/ui/forms/service-request-action-form/service-request-action-form.component.ts + 348 + + + + Identify the equipment requiring maintenance. + + src/app/features/organization/features/service-requests/ui/forms/service-request-action-form/service-request-action-form.component.ts + 356 + + + + Choose current corrective work. + + src/app/features/organization/features/service-requests/ui/forms/service-request-action-form/service-request-action-form.component.ts + 364 + + + + Open corrective work + + src/app/features/organization/features/service-requests/ui/forms/service-request-action-form/service-request-action-form.component.ts + 388 + + + + Confirm qualification + + src/app/features/organization/features/service-requests/ui/forms/service-request-action-form/service-request-action-form.component.ts + 419 + + + + Reject request + + src/app/features/organization/features/service-requests/ui/forms/service-request-action-form/service-request-action-form.component.ts + 421 + + + src/app/features/organization/features/service-requests/ui/pages/service-request-detail-page/service-request-detail-page.component.html + 130,133 + + + src/app/features/organization/features/service-requests/ui/sheets/service-request-editor-sheet/service-request-editor-sheet.component.ts + 546 + + + + Cancel request + + src/app/features/organization/features/service-requests/ui/forms/service-request-action-form/service-request-action-form.component.ts + 423 + + + src/app/features/organization/features/service-requests/ui/pages/service-request-detail-page/service-request-detail-page.component.html + 143,146 + + + src/app/features/organization/features/service-requests/ui/sheets/service-request-editor-sheet/service-request-editor-sheet.component.ts + 548 + + + + Link this corrective work + + src/app/features/organization/features/service-requests/ui/forms/service-request-action-form/service-request-action-form.component.ts + 426 + + + + Create corrective intervention + + src/app/features/organization/features/service-requests/ui/forms/service-request-action-form/service-request-action-form.component.ts + 427 + + + + Maintenance target + + src/app/features/organization/features/service-requests/ui/forms/service-request-form/service-request-form.component.html + 8,10 + + + src/app/features/organization/features/service-requests/ui/pages/service-request-detail-page/service-request-detail-page.component.html + 153,155 + + + src/app/features/organization/features/service-requests/ui/pages/service-requests-page/service-requests-page.component.html + 194,196 + + + + Site + + src/app/features/organization/features/service-requests/ui/forms/service-request-form/service-request-form.component.html + 12,14 + + + src/app/features/organization/features/service-requests/ui/pages/service-request-detail-page/service-request-detail-page.component.html + 182,183 + + + + A site-only request can be qualified once its equipment is identified. + + src/app/features/organization/features/service-requests/ui/forms/service-request-form/service-request-form.component.html + 20,23 + + + + Title + + src/app/features/organization/features/service-requests/ui/forms/service-request-form/service-request-form.component.html + 46,48 + + + src/app/features/organization/features/service-requests/ui/pages/service-requests-page/service-requests-page.component.html + 192,193 + + + + Maintenance need + + src/app/features/organization/features/service-requests/ui/forms/service-request-form/service-request-form.component.html + 57,61 + + + + Priority + + src/app/features/organization/features/service-requests/ui/forms/service-request-form/service-request-form.component.html + 72,74 + + + src/app/features/organization/features/service-requests/ui/pages/service-requests-page/service-requests-page.component.html + 199,200 + + + + Low + + src/app/features/organization/features/service-requests/ui/forms/service-request-form/service-request-form.component.html + 88,91 + + + src/app/features/organization/features/service-requests/utils/service-request-label/service-request-label.utils.ts + 48 + + + + Normal + + src/app/features/organization/features/service-requests/ui/forms/service-request-form/service-request-form.component.html + 97,100 - - - The campaign could not be generated. - src/app/features/organization/features/maintenance-schedules/ui/forms/maintenance-campaign-form/maintenance-campaign-form.component.ts - 338 + src/app/features/organization/features/service-requests/utils/service-request-label/service-request-label.utils.ts + 50 - - Search schedules... + + High - src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-schedules-page/maintenance-schedules-page.component.html - 6,7 + src/app/features/organization/features/service-requests/ui/forms/service-request-form/service-request-form.component.html + 106,109 - src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-schedules-page/maintenance-schedules-page.component.html - 8,9 + src/app/features/organization/features/service-requests/utils/service-request-label/service-request-label.utils.ts + 52 - - Filters + + Urgent - src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-schedules-page/maintenance-schedules-page.component.html - 19 + src/app/features/organization/features/service-requests/ui/forms/service-request-form/service-request-form.component.html + 115,117 - - - Export - src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-schedules-page/maintenance-schedules-page.component.html - 41,43 + src/app/features/organization/features/service-requests/utils/service-request-label/service-request-label.utils.ts + 54 - - Preparing the export… + + Describe the request with a title. - src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-schedules-page/maintenance-schedules-page.component.html - 50,54 + src/app/features/organization/features/service-requests/ui/forms/service-request-form/service-request-form.component.ts + 306 - - - Any status - src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-schedules-page/maintenance-schedules-page.component.html - 73,74 + src/app/features/organization/features/service-requests/ui/forms/service-request-form/service-request-form.component.ts + 322 - - No status matches. + + Describe the maintenance need. - src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-schedules-page/maintenance-schedules-page.component.html - 75,76 + src/app/features/organization/features/service-requests/ui/forms/service-request-form/service-request-form.component.ts + 309 + + + src/app/features/organization/features/service-requests/ui/forms/service-request-form/service-request-form.component.ts + 330 - - Due status + + Use at most 160 characters. - src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-schedules-page/maintenance-schedules-page.component.html - 77,78 + src/app/features/organization/features/service-requests/ui/forms/service-request-form/service-request-form.component.ts + 312 + + + Choose equipment or a site. - src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-schedules-page/maintenance-schedules-page.component.ts - 698 + src/app/features/organization/features/service-requests/ui/forms/service-request-form/service-request-form.component.ts + 338 - - Any type + + Save request - src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-schedules-page/maintenance-schedules-page.component.html - 118,119 + src/app/features/organization/features/service-requests/ui/forms/service-request-form/service-request-form.component.ts + 356 - - Search a type + + Submit maintenance request - src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-schedules-page/maintenance-schedules-page.component.html - 120,121 + src/app/features/organization/features/service-requests/ui/forms/service-request-form/service-request-form.component.ts + 357 - - No type matches. + + Maintenance requests - src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-schedules-page/maintenance-schedules-page.component.html - 122,123 + src/app/features/organization/features/service-requests/ui/pages/service-request-detail-page/service-request-detail-page.component.html + 8,10 - - Equipment type + + Saved maintenance operations could not be restored. Retry before starting another action. - src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-schedules-page/maintenance-schedules-page.component.html - 124,125 + src/app/features/organization/features/service-requests/ui/pages/service-request-detail-page/service-request-detail-page.component.html + 18,22 - src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-schedules-page/maintenance-schedules-page.component.ts - 710 + src/app/features/organization/features/service-requests/ui/pages/service-requests-page/service-requests-page.component.html + 28,32 - - Pick a date + + The work link has not been confirmed. Retry this conversion before choosing another action. - src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-schedules-page/maintenance-schedules-page.component.html - 137,138 + src/app/features/organization/features/service-requests/ui/pages/service-request-detail-page/service-request-detail-page.component.html + 34,37 + + + src/app/features/organization/features/service-requests/ui/sheets/service-request-editor-sheet/service-request-editor-sheet.component.html + 62,65 - - Due before + + Retry the same conversion - src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-schedules-page/maintenance-schedules-page.component.html - 139,140 + src/app/features/organization/features/service-requests/ui/pages/service-request-detail-page/service-request-detail-page.component.html + 49,54 - src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-schedules-page/maintenance-schedules-page.component.ts - 716 + src/app/features/organization/features/service-requests/ui/sheets/service-request-editor-sheet/service-request-editor-sheet.component.html + 73,77 - - Not available with your permissions + + Requested - src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-schedules-page/maintenance-schedules-page.component.html - 154,156 + src/app/features/organization/features/service-requests/ui/pages/service-request-detail-page/service-request-detail-page.component.html + 77,78 - - - Ask an organization admin to grant you read access to maintenance schedules. - src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-schedules-page/maintenance-schedules-page.component.html - 157,160 + src/app/features/organization/features/service-requests/ui/pages/service-requests-page/service-requests-page.component.html + 200,202 - - Couldn't load maintenance schedules + + Qualify request - src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-schedules-page/maintenance-schedules-page.component.html - 168,170 + src/app/features/organization/features/service-requests/ui/pages/service-request-detail-page/service-request-detail-page.component.html + 104,107 - - The schedules could not be loaded. Check your connection and try again. + + Organize corrective work - src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-schedules-page/maintenance-schedules-page.component.html - 171,174 + src/app/features/organization/features/service-requests/ui/pages/service-request-detail-page/service-request-detail-page.component.html + 117,120 - - - Retry - src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-schedules-page/maintenance-schedules-page.component.html - 176,178 + src/app/features/organization/features/service-requests/ui/sheets/service-request-editor-sheet/service-request-editor-sheet.component.ts + 550 - - No schedules match these filters + + Open equipment dossier - src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-schedules-page/maintenance-schedules-page.component.html - 185,187 + src/app/features/organization/features/service-requests/ui/pages/service-request-detail-page/service-request-detail-page.component.html + 175,178 - - Adjust your search or filters and try again. + + Open site dossier - src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-schedules-page/maintenance-schedules-page.component.html - 188,191 + src/app/features/organization/features/service-requests/ui/pages/service-request-detail-page/service-request-detail-page.component.html + 190,193 - - No maintenance schedules yet + + Open source inspection - src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-schedules-page/maintenance-schedules-page.component.html - 201,202 + src/app/features/organization/features/service-requests/ui/pages/service-request-detail-page/service-request-detail-page.component.html + 214,218 - - Track equipment to see its maintenance schedule here. + + Qualification - src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-schedules-page/maintenance-schedules-page.component.html - 203,206 + src/app/features/organization/features/service-requests/ui/pages/service-request-detail-page/service-request-detail-page.component.html + 220,222 - - Go to equipment + + Corrective work - src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-schedules-page/maintenance-schedules-page.component.html - 209,213 + src/app/features/organization/features/service-requests/ui/pages/service-request-detail-page/service-request-detail-page.component.html + 239,240 - - Generate inspection campaign + + Open corrective intervention - src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-schedules-page/maintenance-schedules-page.component.html - 276,279 + src/app/features/organization/features/service-requests/ui/pages/service-request-detail-page/service-request-detail-page.component.html + 258,263 + + + This request could not be opened - src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-schedules-page/maintenance-schedules-page.component.html - 281,286 + src/app/features/organization/features/service-requests/ui/pages/service-request-detail-page/service-request-detail-page.component.html + 267,269 - - Facility + + Describe maintenance needs, qualify their target, then organize corrective work. - src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-schedules-page/maintenance-schedules-page.component.ts - 704 + src/app/features/organization/features/service-requests/ui/pages/service-requests-page/service-requests-page.component.html + 4,7 - - before + + New maintenance request - src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-schedules-page/maintenance-schedules-page.component.ts - 720 + src/app/features/organization/features/service-requests/ui/pages/service-requests-page/service-requests-page.component.html + 16,20 - - - Couldn't export maintenance schedules. - src/app/features/organization/features/maintenance-schedules/ui/pages/maintenance-schedules-page/maintenance-schedules-page.component.ts - 1336 + src/app/features/organization/features/service-requests/ui/pages/service-requests-page/service-requests-page.component.html + 148,150 - - - Maintenance schedules - src/app/features/organization/features/maintenance-schedules/ui/tables/maintenance-schedule-table/maintenance-schedule-table.component.html - 2,3 + src/app/features/organization/features/service-requests/ui/sheets/service-request-editor-sheet/service-request-editor-sheet.component.ts + 540 - - Equipment + + Maintenance request submitted: - src/app/features/organization/features/maintenance-schedules/ui/tables/maintenance-schedule-table/maintenance-schedule-table.component.html - 12,13 + src/app/features/organization/features/service-requests/ui/pages/service-requests-page/service-requests-page.component.html + 44,46 - - Facility + + You are offline. Your open draft is kept until you can submit it. - src/app/features/organization/features/maintenance-schedules/ui/tables/maintenance-schedule-table/maintenance-schedule-table.component.html - 13,14 + src/app/features/organization/features/service-requests/ui/pages/service-requests-page/service-requests-page.component.html + 51,55 - - Last inspected + + Search maintenance requests - src/app/features/organization/features/maintenance-schedules/ui/tables/maintenance-schedule-table/maintenance-schedule-table.component.html - 14,15 + src/app/features/organization/features/service-requests/ui/pages/service-requests-page/service-requests-page.component.html + 62,63 + + + Request status - src/app/features/organization/features/maintenance-schedules/ui/tables/maintenance-schedule-table/maintenance-schedule-table.component.html - 124,125 + src/app/features/organization/features/service-requests/ui/pages/service-requests-page/service-requests-page.component.html + 72,73 - - - Next due - src/app/features/organization/features/maintenance-schedules/ui/tables/maintenance-schedule-table/maintenance-schedule-table.component.html - 15,16 + src/app/features/organization/features/service-requests/ui/pages/service-requests-page/service-requests-page.component.html + 197,199 - - Status + + All statuses - src/app/features/organization/features/maintenance-schedules/ui/tables/maintenance-schedule-table/maintenance-schedule-table.component.html - 16,17 + src/app/features/organization/features/service-requests/ui/pages/service-requests-page/service-requests-page.component.html + 83,85 - - Interval override + + Requests could not be loaded - src/app/features/organization/features/maintenance-schedules/ui/tables/maintenance-schedule-table/maintenance-schedule-table.component.html - 17,19 + src/app/features/organization/features/service-requests/ui/pages/service-requests-page/service-requests-page.component.html + 99,100 + + + No maintenance requests - src/app/features/organization/features/maintenance-schedules/ui/tables/maintenance-schedule-table/maintenance-schedule-table.component.html - 130,131 + src/app/features/organization/features/service-requests/ui/pages/service-requests-page/service-requests-page.component.html + 124,125 - - Organization default + + The current target and filters have no requests to display. - src/app/features/organization/features/maintenance-schedules/ui/tables/maintenance-schedule-table/maintenance-schedule-table.component.html - 72,77 + src/app/features/organization/features/service-requests/ui/pages/service-requests-page/service-requests-page.component.html + 126,129 + + + Clear filters - src/app/features/organization/features/maintenance-schedules/ui/tables/maintenance-schedule-table/maintenance-schedule-table.component.html - 134,138 + src/app/features/organization/features/service-requests/ui/pages/service-requests-page/service-requests-page.component.html + 138,140 - - No results. + + Qualify the maintenance need before explicitly organizing corrective work. - src/app/features/organization/features/maintenance-schedules/ui/tables/maintenance-schedule-table/maintenance-schedule-table.component.html - 149,153 + src/app/features/organization/features/service-requests/ui/sheets/service-request-editor-sheet/service-request-editor-sheet.component.html + 15,18 - - View facility + + The request has changed. Review its latest version before applying your draft. - src/app/features/organization/features/maintenance-schedules/ui/tables/maintenance-schedule-table/maintenance-schedule-table.component.html - 162,166 + src/app/features/organization/features/service-requests/ui/sheets/service-request-editor-sheet/service-request-editor-sheet.component.html + 21,24 - - Unassigned + + Review latest version - src/app/features/organization/features/maintenance-schedules/ui/tables/maintenance-schedule-table/maintenance-schedule-table.component.html - 166,170 + src/app/features/organization/features/service-requests/ui/sheets/service-request-editor-sheet/service-request-editor-sheet.component.html + 32,34 - - Never inspected + + The current request state no longer allows this action. - src/app/features/organization/features/maintenance-schedules/ui/tables/maintenance-schedule-table/maintenance-schedule-table.component.html - 175,177 + src/app/features/organization/features/service-requests/ui/sheets/service-request-editor-sheet/service-request-editor-sheet.component.html + 42,45 - - Edit interval override + + Use reviewed version and keep my draft - src/app/features/organization/features/maintenance-schedules/ui/tables/maintenance-schedule-table/maintenance-schedule-table.component.html - 190,191 + src/app/features/organization/features/service-requests/ui/sheets/service-request-editor-sheet/service-request-editor-sheet.component.html + 54,57 + + + Edit maintenance request - src/app/features/organization/features/maintenance-schedules/ui/tables/maintenance-schedule-table/maintenance-schedule-table.component.html - 192,193 + src/app/features/organization/features/service-requests/ui/sheets/service-request-editor-sheet/service-request-editor-sheet.component.ts + 542 - - Evaluated + + Qualify maintenance request - src/app/features/organization/features/maintenance-schedules/ui/tables/maintenance-schedule-table/maintenance-schedule-table.component.html - 205,206 + src/app/features/organization/features/service-requests/ui/sheets/service-request-editor-sheet/service-request-editor-sheet.component.ts + 544 - - Not yet evaluated + + Requested - src/app/features/organization/features/maintenance-schedules/ui/tables/maintenance-schedule-table/maintenance-schedule-table.component.html - 208,212 + src/app/features/organization/features/service-requests/utils/service-request-label/service-request-label.utils.ts + 21 + + + Qualified - src/app/features/organization/features/maintenance-schedules/ui/tables/maintenance-schedule-table/maintenance-schedule-table.component.ts - 381 + src/app/features/organization/features/service-requests/utils/service-request-label/service-request-label.utils.ts + 23 - - at + + Rejected - src/app/features/organization/features/maintenance-schedules/ui/tables/maintenance-schedule-table/maintenance-schedule-table.component.ts - 275 + src/app/features/organization/features/service-requests/utils/service-request-label/service-request-label.utils.ts + 25 - - Evaluated + + Cancelled - src/app/features/organization/features/maintenance-schedules/ui/tables/maintenance-schedule-table/maintenance-schedule-table.component.ts - 380 + src/app/features/organization/features/service-requests/utils/service-request-label/service-request-label.utils.ts + 27 - - Reminder sent + + Converted into work - src/app/features/organization/features/maintenance-schedules/ui/tables/maintenance-schedule-table/maintenance-schedule-table.component.ts - 407 + src/app/features/organization/features/service-requests/utils/service-request-label/service-request-label.utils.ts + 29 @@ -26828,7 +35029,7 @@ src/app/features/organization/navigation/organization-navigation.config.ts - 439 + 492 @@ -26918,15 +35119,15 @@ src/app/features/organization/navigation/organization-navigation.config.ts - 488 + 533 src/app/features/organization/organization.routes.ts - 112 + 107 src/app/features/organization/organization.routes.ts - 114 + 109 @@ -26950,44 +35151,51 @@ 198 - - Dashboard + + Equipment park src/app/features/organization/navigation/organization-navigation.config.ts 393 src/app/features/organization/organization.routes.ts - 124 + 166 + + + src/app/features/organization/organization.routes.ts + 168 - - Assets + + Dashboard src/app/features/organization/navigation/organization-navigation.config.ts - 471 + 402 src/app/features/organization/organization.routes.ts - 171 + 119 + + + Parts and stock - src/app/features/organization/organization.routes.ts - 173 + src/app/features/organization/navigation/organization-navigation.config.ts + 444 Operations src/app/features/organization/navigation/organization-navigation.config.ts - 506 + 551 Assets src/app/features/organization/navigation/organization-navigation.config.ts - 507 + 552 @@ -27029,7 +35237,7 @@ Choose an organization src/app/features/organization/organization.routes.ts - 96 + 91 src/app/features/organization/ui/pages/organization-select-page/organization-select-page.component.html @@ -27040,33 +35248,33 @@ Members src/app/features/organization/organization.routes.ts - 251 + 285 src/app/features/organization/organization.routes.ts - 252 + 286 Settings src/app/features/organization/organization.routes.ts - 273 + 307 src/app/features/organization/organization.routes.ts - 274 + 308 Profile src/app/features/organization/organization.routes.ts - 282 + 316 src/app/features/organization/organization.routes.ts - 283 + 317 @@ -27624,7 +35832,7 @@ src/app/features/organization/ui/pages/organization-dashboard-page/organization-dashboard-page.component.html - 162,164 + 165,167 @@ -28042,6 +36250,92 @@ 223 + + Park actions + + src/app/features/organization/ui/components/organization-park-queues/organization-park-queues.component.html + 3,4 + + + + Equipment coverage + + src/app/features/organization/ui/components/organization-park-queues/organization-park-queues.component.html + 9,10 + + + src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html + 8,9 + + + + Fire equipment + + src/app/features/organization/ui/components/organization-park-queues/organization-park-queues.component.html + 18,19 + + + src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html + 17,19 + + + + All equipment + + src/app/features/organization/ui/components/organization-park-queues/organization-park-queues.component.html + 25,27 + + + src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html + 20,22 + + + + Site + + src/app/features/organization/ui/components/organization-park-queues/organization-park-queues.component.html + 42,43 + + + + Unavailable equipment + + src/app/features/organization/ui/components/organization-park-queues/organization-park-queues.component.html + 64,65 + + + + Controls to prepare + + src/app/features/organization/ui/components/organization-park-queues/organization-park-queues.component.html + 74,75 + + + + Anomalies to address + + src/app/features/organization/ui/components/organization-park-queues/organization-park-queues.component.html + 86,87 + + + src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html + 544,545 + + + + Some park counts could not be loaded + + src/app/features/organization/ui/components/organization-park-queues/organization-park-queues.component.html + 103,104 + + + + All sites + + src/app/features/organization/ui/components/organization-park-queues/organization-park-queues.component.ts + 374 + + Couldn't load plans @@ -30806,746 +39100,760 @@ 34 + + Show the complete park + + src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html + 45,48 + + Estate axis src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 14,15 + 62,63 By site src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 19,21 + 67,69 Everything src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 27,29 + 75,77 Compliance src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 35,37 + 83,85 Select a site src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 63,64 + 111,112 src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 183,184 + 231,232 Pick a facility from the hierarchy to see its equipment and inspections. src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 65,67 + 113,115 src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 185,187 + 233,235 Loading the facility hierarchy… src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 73,75 + 121,123 Couldn't load the facility hierarchy. src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 86,87 + 134,135 No facilities yet src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 104,105 + 152,153 Create your first site to start tracking equipment and inspections. src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 107,109 + 155,157 No sites have been created yet. src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 111,113 + 159,161 Facility hierarchy src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 132,133 + 180,181 Load more places src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 156,158 + 204,206 Could not load more places. The current hierarchy is preserved; try again. src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 165,167 + 213,215 Back to the hierarchy src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 202,204 + 250,252 src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 347,349 + 395,397 Equipment scope src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 221,223 + 269,271 This place and descendants src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 233,234 + 281,282 Directly assigned src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 244,245 + 292,293 Couldn't load the compliance hierarchy. src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 282,284 + 330,332 Loading the compliance hierarchy… src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 299,301 + 347,349 No facilities yet src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 309,310 + 357,358 Compliance tracking will appear here once a site is created. src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 311,313 + 359,361 Compliance hierarchy src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 317,318 + 365,366 Last inspection src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 364,365 + 412,413 Select a site src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 375,377 + 423,425 Pick a facility from the hierarchy to see its compliance summary. src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 378,380 + 426,428 More children src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 420,424 + 468,472 Open record src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 447,449 + 495,497 Move to… src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 458,460 + 506,508 Duplicate src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 468,471 + 516,519 + + + + No unresolved anomalies in this park scope + + src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html + 550,551 Equipment src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 500,501 + 586,587 src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 509,510 + 595,596 Print QR labels src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 530,532 + 616,618 Preparing the label sheet… src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 539,541 + 625,627 Loading resources… src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 547,548 + 633,634 src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 554,555 + 640,641 src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 774,775 + 869,870 src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 781,782 + 876,877 Equipment src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 559,560 + 645,646 src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 678,679 + 768,769 Facility src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 562,563 + 648,649 src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 681,683 + 771,772 Status src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 565,566 + 651,652 src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 684,686 + 774,775 Maintenance src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 568,570 + 658,659 src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 687,689 + 777,779 Couldn't load equipment. src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 589,591 + 679,681 No equipment in view. src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 614,615 + 704,705 No equipment is assigned to this scope yet. src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 616,618 + 706,708 Inspections src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 754,755 + 849,850 src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 763,764 + 858,859 Performed src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 786,787 + 881,882 src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 907,909 + 1013,1014 Inspector src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 789,791 + 888,889 src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 910,912 + 1016,1018 Result src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 792,793 + 891,892 src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 913,915 + 1019,1020 Status src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 795,796 + 894,895 src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 916,918 + 1022,1023 Non-conformities src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 802,804 + 901,903 src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 923,925 + 1029,1031 - Couldn't load inspections. + Couldn't load inspections. src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 823,825 + 923,925 No inspections in view. src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 848,849 + 950,951 No inspection has been recorded for this scope yet. src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 850,853 + 952,954 {VAR_PLURAL, plural, =1 {1 non-conformity} other { non-conformities}} src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 890,892 + 996,998 Monitoring summary src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 1038,1040 + 1147,1149 Monitoring status according to the rules configured in this organization. src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 1041,1045 + 1150,1154 Couldn't load the compliance summary. src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 1051,1054 + 1160,1163 Loading compliance summary src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 1071,1072 + 1180,1181 Generated src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 1100,1102 + 1209,1211 Oldest evaluation src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 1114,1116 + 1223,1225 Evaluation date unavailable src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 1127,1131 + 1236,1240 Equipment not yet evaluated src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 1139,1140 + 1248,1249 Exporting… src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 1217,1219 + 1326,1328 src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 1230,1232 + 1339,1341 Export safety register src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 1219,1221 + 1328,1330 src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 1229,1230 + 1338,1339 Archiving… src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 1247,1249 + 1356,1358 src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 1260,1262 + 1369,1371 Archive register src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 1249,1251 + 1358,1360 src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 1259,1260 + 1368,1369 Equipment up-to-date rate src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 1270,1271 + 1379,1380 Couldn't export the safety register. src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 1282,1285 + 1391,1394 Total equipment src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 1293,1295 + 1402,1404 Up to date src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 1299,1301 + 1408,1410 Due soon src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 1305,1307 + 1414,1416 Overdue src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 1311,1313 + 1420,1422 Not scheduled src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 1316,1318 + 1425,1427 Critical / high non-conformities src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 1329,1332 + 1438,1441 Medium / low non-conformities src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 1343,1345 + 1452,1454 Archived registers src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 1362,1365 + 1471,1474 Couldn't load the archived registers. src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 1372,1375 + 1481,1484 Loading archived registers src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 1385,1386 + 1494,1495 Generated src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 1400,1402 + 1509,1511 src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 1499,1501 + 1608,1610 Scope src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 1403,1405 + 1512,1514 src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 1502,1504 + 1611,1613 Size src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 1411,1413 + 1520,1522 src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 1510,1512 + 1619,1621 Fingerprint src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 1414,1416 + 1523,1525 src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 1513,1515 + 1622,1624 Actions src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 1418,1420 + 1527,1529 src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 1517,1519 + 1626,1628 No archived register yet. "Archive register" stores a dated copy of the safety register. src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 1444,1447 + 1553,1556 Download this archived register src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 1476,1477 + 1585,1586 src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 1546,1547 + 1655,1656 src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.ts - 619 + 627 Download src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.html - 1552,1554 + 1661,1663 Open menu src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.ts - 577 + 585 Safety register archived. src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.ts - 1308 + 1489 Couldn't archive the safety register. src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.ts - 1318 + 1499 Couldn't download the archived register. src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.ts - 1331 + 1512 Couldn't print the QR labels. src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.ts - 1550 + 1841 Site src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.ts - 1705 + 1996 Organization src/app/features/organization/ui/pages/organization-assets-page/organization-assets-page.component.ts - 1706 + 1997 @@ -31580,387 +39888,387 @@ Not available with your permissions src/app/features/organization/ui/pages/organization-dashboard-page/organization-dashboard-page.component.html - 7,10 + 10,13 Couldn't load the dashboard src/app/features/organization/ui/pages/organization-dashboard-page/organization-dashboard-page.component.html - 17,18 + 20,21 Summary figures, alerts and the severity breakdown couldn't be fetched. src/app/features/organization/ui/pages/organization-dashboard-page/organization-dashboard-page.component.html - 19,22 + 22,25 Retry src/app/features/organization/ui/pages/organization-dashboard-page/organization-dashboard-page.component.html - 31,33 + 34,36 src/app/features/organization/ui/pages/organization-dashboard-page/organization-dashboard-page.component.html - 205,207 + 208,210 src/app/features/organization/ui/pages/organization-dashboard-page/organization-dashboard-page.component.html - 262,264 + 265,267 src/app/features/organization/ui/pages/organization-dashboard-page/organization-dashboard-page.component.html - 457,459 + 460,462 src/app/features/organization/ui/pages/organization-dashboard-page/organization-dashboard-page.component.html - 503,505 + 506,508 Data as of src/app/features/organization/ui/pages/organization-dashboard-page/organization-dashboard-page.component.html - 40,41 + 43,44 Activity src/app/features/organization/ui/pages/organization-dashboard-page/organization-dashboard-page.component.html - 80,81 + 83,84 Period controls apply to activity charts only. src/app/features/organization/ui/pages/organization-dashboard-page/organization-dashboard-page.component.html - 82,85 + 85,88 Time period src/app/features/organization/ui/pages/organization-dashboard-page/organization-dashboard-page.component.html - 93,94 + 96,97 7D src/app/features/organization/ui/pages/organization-dashboard-page/organization-dashboard-page.component.html - 103,105 + 106,108 30D src/app/features/organization/ui/pages/organization-dashboard-page/organization-dashboard-page.component.html - 111,113 + 114,116 90D src/app/features/organization/ui/pages/organization-dashboard-page/organization-dashboard-page.component.html - 119,121 + 122,124 12M src/app/features/organization/ui/pages/organization-dashboard-page/organization-dashboard-page.component.html - 127,129 + 130,132 Compare src/app/features/organization/ui/pages/organization-dashboard-page/organization-dashboard-page.component.html - 137,140 + 140,143 Updating charts… Previous period remains visible. src/app/features/organization/ui/pages/organization-dashboard-page/organization-dashboard-page.component.html - 153,156 + 156,159 Risks and follow-up src/app/features/organization/ui/pages/organization-dashboard-page/organization-dashboard-page.component.html - 160,161 + 163,164 Additional analysis src/app/features/organization/ui/pages/organization-dashboard-page/organization-dashboard-page.component.html - 301,303 + 304,306 Health src/app/features/organization/ui/pages/organization-dashboard-page/organization-dashboard-page.component.html - 312,313 + 315,316 Current snapshot, independent of the selected period src/app/features/organization/ui/pages/organization-dashboard-page/organization-dashboard-page.component.html - 314,316 + 317,319 Loading the health snapshot src/app/features/organization/ui/pages/organization-dashboard-page/organization-dashboard-page.component.html - 322,323 + 325,326 Non-conformities by severity src/app/features/organization/ui/pages/organization-dashboard-page/organization-dashboard-page.component.html - 361,363 + 364,366 Loading severity breakdown… src/app/features/organization/ui/pages/organization-dashboard-page/organization-dashboard-page.component.html - 375,376 + 378,379 Severity breakdown unavailable src/app/features/organization/ui/pages/organization-dashboard-page/organization-dashboard-page.component.html - 389,391 + 392,394 No non-conformities recorded. src/app/features/organization/ui/pages/organization-dashboard-page/organization-dashboard-page.component.html - 398,400 + 401,403 overdue src/app/features/organization/ui/pages/organization-dashboard-page/organization-dashboard-page.component.ts - 574 + 576 Open interventions src/app/features/organization/ui/pages/organization-dashboard-page/organization-dashboard-page.component.ts - 584 + 586 Non-conformities to resolve src/app/features/organization/ui/pages/organization-dashboard-page/organization-dashboard-page.component.ts - 598 + 600 Inspections completed src/app/features/organization/ui/pages/organization-dashboard-page/organization-dashboard-page.component.ts - 610 + 612 Equipment under maintenance src/app/features/organization/ui/pages/organization-dashboard-page/organization-dashboard-page.component.ts - 623 + 625 Not available with your permissions. src/app/features/organization/ui/pages/organization-dashboard-page/organization-dashboard-page.component.ts - 742 + 744 This chart could not be loaded. src/app/features/organization/ui/pages/organization-dashboard-page/organization-dashboard-page.component.ts - 756 + 758 Inspections performed src/app/features/organization/ui/pages/organization-dashboard-page/organization-dashboard-page.component.ts - 770 + 772 Inspections performed over time src/app/features/organization/ui/pages/organization-dashboard-page/organization-dashboard-page.component.ts - 784 + 786 Non-conformities opened vs resolved src/app/features/organization/ui/pages/organization-dashboard-page/organization-dashboard-page.component.ts - 798 + 800 Non-conformities opened and resolved over time src/app/features/organization/ui/pages/organization-dashboard-page/organization-dashboard-page.component.ts - 812 + 814 Opened src/app/features/organization/ui/pages/organization-dashboard-page/organization-dashboard-page.component.ts - 826 + 828 Resolved src/app/features/organization/ui/pages/organization-dashboard-page/organization-dashboard-page.component.ts - 840 + 842 Equipment added src/app/features/organization/ui/pages/organization-dashboard-page/organization-dashboard-page.component.ts - 854 + 856 Equipment added over time src/app/features/organization/ui/pages/organization-dashboard-page/organization-dashboard-page.component.ts - 868 + 870 Facilities added src/app/features/organization/ui/pages/organization-dashboard-page/organization-dashboard-page.component.ts - 882 + 884 Facilities added over time src/app/features/organization/ui/pages/organization-dashboard-page/organization-dashboard-page.component.ts - 896 + 898 1 recorded, across all statuses src/app/features/organization/ui/pages/organization-dashboard-page/organization-dashboard-page.component.ts - 960 + 962 recorded, across all statuses src/app/features/organization/ui/pages/organization-dashboard-page/organization-dashboard-page.component.ts - 961 + 963 Inspection pass rate src/app/features/organization/ui/pages/organization-dashboard-page/organization-dashboard-page.component.ts - 1022 + 1024 Equipment availability src/app/features/organization/ui/pages/organization-dashboard-page/organization-dashboard-page.component.ts - 1030 + 1032 Non-conformity resolution rate src/app/features/organization/ui/pages/organization-dashboard-page/organization-dashboard-page.component.ts - 1038 + 1040 opened, resolved this period src/app/features/organization/ui/pages/organization-dashboard-page/organization-dashboard-page.component.ts - 1155 + 1157 Unrecognized alert src/app/features/organization/ui/pages/organization-dashboard-page/organization-dashboard-page.component.ts - 1284 + 1286 No previous-period baseline src/app/features/organization/ui/pages/organization-dashboard-page/organization-dashboard-page.component.ts - 1366 + 1368 % vs previous period src/app/features/organization/ui/pages/organization-dashboard-page/organization-dashboard-page.component.ts - 1371 + 1373 total this period src/app/features/organization/ui/pages/organization-dashboard-page/organization-dashboard-page.component.ts - 1549 + 1551 total this period, % vs previous period src/app/features/organization/ui/pages/organization-dashboard-page/organization-dashboard-page.component.ts - 1557 + 1559 overdue src/app/features/organization/ui/pages/organization-dashboard-page/organization-dashboard-page.component.ts - 1577 + 1579 On track src/app/features/organization/ui/pages/organization-dashboard-page/organization-dashboard-page.component.ts - 1584 + 1586 diff --git a/tests/contracts/fixtures/openapi.json b/tests/contracts/fixtures/openapi.json index aca0ab364..8837491a1 100644 --- a/tests/contracts/fixtures/openapi.json +++ b/tests/contracts/fixtures/openapi.json @@ -1,8 +1,8 @@ { "openapi": "3.2.0", "info": { - "title": "Fireguard API", - "description": "Fire-safety compliance platform API — identity, organizations, facilities, equipment, inspections, interventions, maintenance and compliance", + "title": "FireGuard API", + "description": "Fire-equipment park and maintenance API — internal customers, sites, equipment, controls, interventions, parts, procurement and private maintenance costs", "version": "1.0.0" }, "servers": [ @@ -9664,20 +9664,20 @@ } } }, - "/api/direct-conversations": { + "/api/organizations/{organizationId}/customers": { "get": { - "operationId": "api_direct-conversations_get_collection", + "operationId": "list_customers", "tags": [ - "DirectConversation" + "Customer" ], "responses": { "200": { - "description": "DirectConversation collection", + "description": "Customer collection", "content": { "application/ld+json": { "schema": { "type": "object", - "description": "DirectConversation.ConversationOutput.jsonld collection.", + "description": "Customer.CustomerOutput.jsonld-customer.read collection.", "allOf": [ { "$ref": "#/components/schemas/HydraCollectionBaseSchema" @@ -9691,7 +9691,7 @@ "member": { "type": "array", "items": { - "$ref": "#/components/schemas/DirectConversation.ConversationOutput.jsonld" + "$ref": "#/components/schemas/Customer.CustomerOutput.jsonld-customer.read" } } } @@ -9723,75 +9723,88 @@ "links": {} } }, - "summary": "Retrieves the collection of DirectConversation resources.", - "description": "Retrieves the collection of DirectConversation resources.", + "summary": "Search internal customers", + "description": "Search internal customers", "parameters": [ { - "name": "page", + "name": "search", "in": "query", - "description": "The collection page number", + "description": "", "required": false, "deprecated": false, "schema": { - "type": "integer", - "default": 1 + "type": "string", + "maxLength": 160 }, "style": "form", "explode": true }, { - "name": "itemsPerPage", + "name": "archived", "in": "query", - "description": "The number of items per page", + "description": "", "required": false, "deprecated": false, "schema": { - "type": "integer", - "default": 30, - "minimum": 0, - "maximum": 100 + "type": "boolean", + "default": false }, "style": "form", "explode": true }, { - "name": "organization", + "name": "page", "in": "query", - "description": "Organization IRI.", - "required": true, + "description": "", + "required": false, "deprecated": false, "schema": { - "type": "string" + "type": "integer", + "minimum": 1 }, "style": "form", "explode": true }, { - "name": "isArchived", + "name": "itemsPerPage", "in": "query", - "description": "Archived-flag filter.", + "description": "", "required": false, "deprecated": false, "schema": { - "type": "boolean" + "type": "integer", + "minimum": 1, + "maximum": 100 }, "style": "form", "explode": true + }, + { + "name": "organizationId", + "in": "path", + "description": "Customer identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false } ] }, "post": { - "operationId": "api_direct-conversations_post", + "operationId": "create_customer", "tags": [ - "DirectConversation" + "Customer" ], "responses": { - "200": { - "description": "DirectConversation resource created", + "201": { + "description": "Customer resource created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/DirectConversation.ConversationOutput.jsonld" + "$ref": "#/components/schemas/Customer.CustomerOutput.jsonld-customer.read" } } }, @@ -9861,15 +9874,28 @@ "links": {} } }, - "summary": "Creates a DirectConversation resource.", - "description": "Creates a DirectConversation resource.", - "parameters": [], + "summary": "Create an internal customer", + "description": "Create an internal customer", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "Customer identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], "requestBody": { - "description": "The new DirectConversation resource", + "description": "The new Customer resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/DirectConversation.GetOrCreateDirectConversationInput" + "$ref": "#/components/schemas/Customer.CreateCustomerInput-customer.write" } } }, @@ -9877,19 +9903,19 @@ } } }, - "/api/auth/email-ownership": { + "/api/organizations/{organizationId}/customers/{id}": { "get": { - "operationId": "auth_email_ownership_get", + "operationId": "get_customer", "tags": [ - "EmailOwnership" + "Customer" ], "responses": { "200": { - "description": "EmailOwnership resource", + "description": "Customer resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/EmailOwnership.EmailOwnershipOutput.jsonld-email_ownership.read" + "$ref": "#/components/schemas/Customer.CustomerOutput.jsonld-customer.read" } } } @@ -9937,24 +9963,174 @@ "links": {} } }, - "summary": "Read explicit Fireguard mailbox ownership proof", - "description": "Read explicit Fireguard mailbox ownership proof", - "parameters": [] + "summary": "Read an internal customer", + "description": "Read an internal customer", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "Customer identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "id", + "in": "path", + "description": "Customer identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ] + }, + "patch": { + "operationId": "patch_customer", + "tags": [ + "Customer" + ], + "responses": { + "200": { + "description": "Customer updated", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Customer.CustomerOutput.jsonld-customer.read" + } + } + }, + "links": {} + }, + "400": { + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "403": { + "description": "Permission denied" + }, + "404": { + "description": "Customer not found" + }, + "412": { + "description": "Stale revision" + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + }, + "428": { + "description": "If-Match required" + } + }, + "summary": "Update an internal customer", + "description": "Update an internal customer", + "parameters": [ + { + "name": "If-Match", + "in": "header", + "description": "", + "required": true, + "deprecated": false, + "schema": { + "type": "string", + "example": "\"revision-1\"" + }, + "style": "simple", + "explode": false + }, + { + "name": "organizationId", + "in": "path", + "description": "Customer identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "id", + "in": "path", + "description": "Customer identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], + "requestBody": { + "description": "The updated Customer resource", + "content": { + "application/merge-patch+json": { + "schema": { + "$ref": "#/components/schemas/Customer.ChangeCustomerInput-customer.write.jsonMergePatch" + } + } + }, + "required": true + } } }, - "/api/auth/email-ownership/confirm": { + "/api/organizations/{organizationId}/customers/{id}/archive": { "post": { - "operationId": "auth_email_ownership_confirm", + "operationId": "archive_customer", "tags": [ - "EmailOwnership" + "Customer" ], "responses": { "200": { - "description": "EmailOwnership resource created", + "description": "Customer resource created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/EmailOwnership.EmailOwnershipOutput.jsonld-email_ownership.read" + "$ref": "#/components/schemas/Customer.CustomerOutput.jsonld-customer.read" } } }, @@ -10024,35 +10200,61 @@ "links": {} } }, - "summary": "Confirm current mailbox possession with a single-use email code", - "description": "Confirm current mailbox possession with a single-use email code", - "parameters": [], - "requestBody": { - "description": "The new EmailOwnership resource", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/EmailOwnership.ConfirmEmailOwnershipInput-email_ownership.write" - } - } + "summary": "Archive an internal customer", + "description": "Archive an internal customer", + "parameters": [ + { + "name": "If-Match", + "in": "header", + "description": "", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false }, - "required": true - } + { + "name": "organizationId", + "in": "path", + "description": "Customer identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "id", + "in": "path", + "description": "Customer identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ] } }, - "/api/auth/email-ownership/start": { + "/api/organizations/{organizationId}/customers/{id}/restore": { "post": { - "operationId": "auth_email_ownership_start", + "operationId": "restore_customer", "tags": [ - "EmailOwnership" + "Customer" ], "responses": { "200": { - "description": "EmailOwnership resource created", + "description": "Customer resource created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/EmailOwnership.StartEmailOwnershipOutput.jsonld-email_ownership.read" + "$ref": "#/components/schemas/Customer.CustomerOutput.jsonld-customer.read" } } }, @@ -10122,25 +10324,62 @@ "links": {} } }, - "summary": "Send a code to the authenticated account current email", - "description": "Send a code to the authenticated account current email", - "parameters": [] + "summary": "Restore an internal customer", + "description": "Restore an internal customer", + "parameters": [ + { + "name": "If-Match", + "in": "header", + "description": "", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "organizationId", + "in": "path", + "description": "Customer identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "id", + "in": "path", + "description": "Customer identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ] } }, - "/api/equipment": { + "/api/direct-conversations": { "get": { - "operationId": "api_equipment_get_collection", + "operationId": "api_direct-conversations_get_collection", "tags": [ - "Equipment" + "DirectConversation" ], "responses": { "200": { - "description": "Equipment collection", + "description": "DirectConversation collection", "content": { "application/ld+json": { "schema": { "type": "object", - "description": "Equipment.EquipmentOutput.jsonld collection.", + "description": "DirectConversation.ConversationOutput.jsonld collection.", "allOf": [ { "$ref": "#/components/schemas/HydraCollectionBaseSchema" @@ -10154,7 +10393,7 @@ "member": { "type": "array", "items": { - "$ref": "#/components/schemas/Equipment.EquipmentOutput.jsonld" + "$ref": "#/components/schemas/DirectConversation.ConversationOutput.jsonld" } } } @@ -10186,8 +10425,8 @@ "links": {} } }, - "summary": "Retrieves the collection of Equipment resources.", - "description": "Retrieves the collection of Equipment resources.", + "summary": "Retrieves the collection of DirectConversation resources.", + "description": "Retrieves the collection of DirectConversation resources.", "parameters": [ { "name": "page", @@ -10210,7 +10449,7 @@ "deprecated": false, "schema": { "type": "integer", - "default": 50, + "default": 30, "minimum": 0, "maximum": 100 }, @@ -10220,32 +10459,8 @@ { "name": "organization", "in": "query", - "description": "Organization IRI. Required when intervention is omitted.", - "required": false, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "form", - "explode": true - }, - { - "name": "intervention", - "in": "query", - "description": "Intervention IRI. Also scopes the organization and defaults recordStatus to draft.", - "required": false, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "form", - "explode": true - }, - { - "name": "facility", - "in": "query", - "description": "Facility IRI.", - "required": false, + "description": "Organization IRI.", + "required": true, "deprecated": false, "schema": { "type": "string" @@ -10254,17 +10469,13 @@ "explode": true }, { - "name": "recordStatus", + "name": "isArchived", "in": "query", - "description": "Lifecycle status of the representation.", + "description": "Archived-flag filter.", "required": false, "deprecated": false, "schema": { - "type": "string", - "enum": [ - "draft", - "published" - ] + "type": "boolean" }, "style": "form", "explode": true @@ -10272,17 +10483,17 @@ ] }, "post": { - "operationId": "api_equipment_post", + "operationId": "api_direct-conversations_post", "tags": [ - "Equipment" + "DirectConversation" ], "responses": { - "201": { - "description": "Equipment resource created", + "200": { + "description": "DirectConversation resource created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Equipment.EquipmentOutput.jsonld" + "$ref": "#/components/schemas/DirectConversation.ConversationOutput.jsonld" } } }, @@ -10352,15 +10563,15 @@ "links": {} } }, - "summary": "Creates a Equipment resource.", - "description": "Creates a Equipment resource.", + "summary": "Creates a DirectConversation resource.", + "description": "Creates a DirectConversation resource.", "parameters": [], "requestBody": { - "description": "The new Equipment resource", + "description": "The new DirectConversation resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Equipment.CreateEquipmentInput" + "$ref": "#/components/schemas/DirectConversation.GetOrCreateDirectConversationInput" } } }, @@ -10368,19 +10579,19 @@ } } }, - "/api/equipment/{id}": { + "/api/auth/email-ownership": { "get": { - "operationId": "api_equipment_id_get", + "operationId": "auth_email_ownership_get", "tags": [ - "Equipment" + "EmailOwnership" ], "responses": { "200": { - "description": "Equipment resource", + "description": "EmailOwnership resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Equipment.EquipmentOutput.jsonld" + "$ref": "#/components/schemas/EmailOwnership.EmailOwnershipOutput.jsonld-email_ownership.read" } } } @@ -10428,35 +10639,24 @@ "links": {} } }, - "summary": "Retrieves a Equipment resource.", - "description": "Retrieves a Equipment resource.", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "Equipment identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - } - ] - }, - "put": { - "operationId": "canonical_equipment_put", + "summary": "Read explicit Fireguard mailbox ownership proof", + "description": "Read explicit Fireguard mailbox ownership proof", + "parameters": [] + } + }, + "/api/auth/email-ownership/confirm": { + "post": { + "operationId": "auth_email_ownership_confirm", "tags": [ - "Equipment" + "EmailOwnership" ], "responses": { - "201": { - "description": "Equipment resource updated", + "200": { + "description": "EmailOwnership resource created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Equipment.EquipmentOutput.jsonld" + "$ref": "#/components/schemas/EmailOwnership.EmailOwnershipOutput.jsonld-email_ownership.read" } } }, @@ -10504,27 +10704,6 @@ }, "links": {} }, - "404": { - "description": "Not found", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "links": {} - }, "422": { "description": "An error occurred", "content": { @@ -10547,45 +10726,42 @@ "links": {} } }, - "summary": "Replaces the Equipment resource.", - "description": "Replaces the Equipment resource.", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "Equipment identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - } - ], + "summary": "Confirm current mailbox possession with a single-use email code", + "description": "Confirm current mailbox possession with a single-use email code", + "parameters": [], "requestBody": { - "description": "The updated Equipment resource", + "description": "The new EmailOwnership resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Equipment.CreateEquipmentInput" + "$ref": "#/components/schemas/EmailOwnership.ConfirmEmailOwnershipInput-email_ownership.write" } } }, "required": true } - }, - "delete": { - "operationId": "canonical_equipment_delete", + } + }, + "/api/auth/email-ownership/start": { + "post": { + "operationId": "auth_email_ownership_start", "tags": [ - "Equipment" + "EmailOwnership" ], "responses": { - "204": { - "description": "Equipment resource deleted" + "200": { + "description": "EmailOwnership resource created", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/EmailOwnership.StartEmailOwnershipOutput.jsonld-email_ownership.read" + } + } + }, + "links": {} }, - "403": { - "description": "Forbidden", + "400": { + "description": "Invalid input", "content": { "application/ld+json": { "schema": { @@ -10605,106 +10781,8 @@ }, "links": {} }, - "404": { - "description": "Not found", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "links": {} - } - }, - "summary": "Removes the Equipment resource.", - "description": "Removes the Equipment resource.", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "Equipment identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - } - ] - }, - "patch": { - "operationId": "canonical_equipment_patch", - "tags": [ - "Equipment" - ], - "responses": { - "200": { - "description": "Equipment resource updated", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Equipment.EquipmentOutput.jsonld" - } - } - }, - "links": {} - }, - "400": { - "description": "Invalid input", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "links": {} - }, - "403": { - "description": "Forbidden", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "links": {} - }, - "404": { - "description": "Not found", + "403": { + "description": "Forbidden", "content": { "application/ld+json": { "schema": { @@ -10746,49 +10824,25 @@ "links": {} } }, - "summary": "Updates the Equipment resource.", - "description": "Updates the Equipment resource.", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "Equipment identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - } - ], - "requestBody": { - "description": "The updated Equipment resource", - "content": { - "application/merge-patch+json": { - "schema": { - "$ref": "#/components/schemas/Equipment.PatchCanonicalEquipmentInput.jsonMergePatch" - } - } - }, - "required": true - } + "summary": "Send a code to the authenticated account current email", + "description": "Send a code to the authenticated account current email", + "parameters": [] } }, - "/api/organizations/{organizationId}/equipment": { + "/api/equipment": { "get": { - "operationId": "equipment_list", + "operationId": "api_equipment_get_collection", "tags": [ "Equipment" ], "responses": { "200": { - "description": "Equipment list retrieved", + "description": "Equipment collection", "content": { "application/ld+json": { "schema": { "type": "object", - "description": "LegacyEquipment.EquipmentOutput.jsonld-equipment.read collection.", + "description": "Equipment.EquipmentOutput.jsonld collection.", "allOf": [ { "$ref": "#/components/schemas/HydraCollectionBaseSchema" @@ -10802,7 +10856,7 @@ "member": { "type": "array", "items": { - "$ref": "#/components/schemas/LegacyEquipment.EquipmentOutput.jsonld-equipment.read" + "$ref": "#/components/schemas/Equipment.EquipmentOutput.jsonld" } } } @@ -10812,28 +10866,31 @@ } } }, - "400": { - "description": "Invalid organization identifier" - }, "403": { - "description": "Insufficient permissions" + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} } }, - "summary": "List equipment", - "description": "Lists equipment items for the target organization.", + "summary": "Retrieves the collection of Equipment resources.", + "description": "Retrieves the collection of Equipment resources.", "parameters": [ - { - "name": "organizationId", - "in": "path", - "description": "LegacyEquipment identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - }, { "name": "page", "in": "query", @@ -10855,67 +10912,17 @@ "deprecated": false, "schema": { "type": "integer", - "default": 30, + "default": 50, "minimum": 0, - "maximum": 200 - }, - "style": "form", - "explode": true - }, - { - "name": "includeDescendants", - "in": "query", - "description": "Include equipment assigned to published descendants of the facility filter.", - "required": false, - "deprecated": false, - "schema": { - "type": "boolean", - "default": false - }, - "style": "form", - "explode": true - }, - { - "name": "facilityId", - "in": "query", - "description": "Filter by facility identifier.", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "format": "uuid" - }, - "style": "form", - "explode": true - }, - { - "name": "type", - "in": "query", - "description": "Filter by equipment type.", - "required": false, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "form", - "explode": true - }, - { - "name": "status", - "in": "query", - "description": "Filter by equipment status.", - "required": false, - "deprecated": false, - "schema": { - "type": "string" + "maximum": 100 }, "style": "form", "explode": true }, { - "name": "brand", + "name": "organization", "in": "query", - "description": "Filter by exact equipment brand.", + "description": "Organization IRI. Required when intervention is omitted.", "required": false, "deprecated": false, "schema": { @@ -10925,9 +10932,9 @@ "explode": true }, { - "name": "model", + "name": "intervention", "in": "query", - "description": "Filter by exact equipment model.", + "description": "Intervention IRI. Also scopes the organization and defaults recordStatus to draft.", "required": false, "deprecated": false, "schema": { @@ -10937,9 +10944,9 @@ "explode": true }, { - "name": "subType", + "name": "facility", "in": "query", - "description": "Filter by exact equipment subtype.", + "description": "Facility IRI.", "required": false, "deprecated": false, "schema": { @@ -10949,18 +10956,16 @@ "explode": true }, { - "name": "maintenanceDueStatus", + "name": "recordStatus", "in": "query", - "description": "Filter by cross-module maintenance due status (`unscheduled`, `up_to_date`, `due_soon`, `overdue`).", + "description": "Lifecycle status of the representation.", "required": false, "deprecated": false, "schema": { "type": "string", "enum": [ - "unscheduled", - "up_to_date", - "due_soon", - "overdue" + "draft", + "published" ] }, "style": "form", @@ -10969,17 +10974,17 @@ ] }, "post": { - "operationId": "equipment_create", + "operationId": "api_equipment_post", "tags": [ "Equipment" ], "responses": { "201": { - "description": "Equipment created", + "description": "Equipment resource created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/LegacyEquipment.EquipmentOutput.jsonld-equipment.read" + "$ref": "#/components/schemas/Equipment.EquipmentOutput.jsonld" } } }, @@ -11007,10 +11012,25 @@ "links": {} }, "403": { - "description": "Insufficient permissions" - }, - "409": { - "description": "Serial number already exists in this organization" + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} }, "422": { "description": "An error occurred", @@ -11034,28 +11054,15 @@ "links": {} } }, - "summary": "Create equipment", - "description": "Creates a new equipment item in the organization stock.", - "parameters": [ - { - "name": "organizationId", - "in": "path", - "description": "LegacyEquipment identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - } - ], + "summary": "Creates a Equipment resource.", + "description": "Creates a Equipment resource.", + "parameters": [], "requestBody": { - "description": "The new LegacyEquipment resource", + "description": "The new Equipment resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/LegacyEquipment.CreateEquipmentInput-equipment.write" + "$ref": "#/components/schemas/Equipment.CreateEquipmentInput" } } }, @@ -11063,39 +11070,73 @@ } } }, - "/api/organizations/{organizationId}/equipment/export": { + "/api/equipment/{id}": { "get": { - "operationId": "equipment_export", + "operationId": "api_equipment_id_get", "tags": [ "Equipment" ], "responses": { "200": { - "description": "CSV export streamed successfully" - }, - "204": { - "description": "EquipmentExport resource" - }, - "400": { - "description": "Missing organizationId URI parameter" + "description": "Equipment resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Equipment.EquipmentOutput.jsonld" + } + } + } }, "403": { - "description": "Authenticated but missing organization.equipment.read" + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} }, "404": { - "description": "The organization is outside the caller's scope" - }, - "422": { - "description": "Export exceeds the row cap" + "description": "Not found", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} } }, - "summary": "Export equipment (CSV)", - "description": "Streams a CSV export of every equipment item (Content-Disposition: attachment) for the given organization. Requires `organization.equipment.read` on the organization, resolved the same way the list endpoint resolves it — a resource-level ROLE_USER check alone does not grant access. The first six columns (type, subType, brand, model, serialNumber, locationLabel) are the import round-trip contract read back by the bulk CSV import; the remaining columns are read-only metadata. Bounded to 50000 matching rows — the request is rejected with 422 if the organization has more.", + "summary": "Retrieves a Equipment resource.", + "description": "Retrieves a Equipment resource.", "parameters": [ { - "name": "organizationId", + "name": "id", "in": "path", - "description": "EquipmentExport identifier", + "description": "Equipment identifier", "required": true, "deprecated": false, "schema": { @@ -11104,36 +11145,66 @@ "style": "simple", "explode": false } - ], - "security": [ - { - "bearerAuth": [] - } ] - } - }, - "/api/organizations/{organizationId}/equipment/kpis": { - "get": { - "operationId": "equipment_get_kpis", + }, + "put": { + "operationId": "canonical_equipment_put", "tags": [ "Equipment" ], "responses": { - "200": { - "description": "Equipment KPIs retrieved", + "201": { + "description": "Equipment resource updated", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/EquipmentKpi.EquipmentKpiOutput.jsonld-equipment.read" + "$ref": "#/components/schemas/Equipment.EquipmentOutput.jsonld" } } - } + }, + "links": {} }, "400": { - "description": "Invalid organization identifier" + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} }, "403": { - "description": "Insufficient permissions" + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} }, "404": { "description": "Not found", @@ -11155,15 +11226,36 @@ } }, "links": {} + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} } }, - "summary": "Get equipment KPIs", - "description": "Returns the four headline counters for the organization equipment overview: `totalAssets` (every status), `compliant` (cross-module maintenance due status `up_to_date`), `dueSoon` (cross-module maintenance due status `due_soon`), and `openNonConformities`. The last counter is deliberately ORGANIZATION-WIDE, not equipment-scoped: non-conformities attach to inspections, not to equipment, so there is no reliable per-equipment open-non-conformity count (see `src/Equipment/MODULE.md`).", + "summary": "Replaces the Equipment resource.", + "description": "Replaces the Equipment resource.", "parameters": [ { - "name": "organizationId", + "name": "id", "in": "path", - "description": "EquipmentKpi identifier", + "description": "Equipment identifier", "required": true, "deprecated": false, "schema": { @@ -11172,42 +11264,78 @@ "style": "simple", "explode": false } - ] - } - }, - "/api/organizations/{organizationId}/equipment/labels": { - "get": { - "operationId": "equipment_export_labels", + ], + "requestBody": { + "description": "The updated Equipment resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Equipment.CreateEquipmentInput" + } + } + }, + "required": true + } + }, + "delete": { + "operationId": "canonical_equipment_delete", "tags": [ "Equipment" ], "responses": { - "200": { - "description": "QR label sheet PDF" - }, "204": { - "description": "EquipmentLabelSheet resource" - }, - "400": { - "description": "Missing organizationId, both ids[] and facilityId provided, or empty ids[]" + "description": "Equipment resource deleted" }, "403": { - "description": "Missing organization.equipment.read permission" + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} }, "404": { - "description": "Organization outside the caller's scope" - }, - "422": { - "description": "Selection matches more than 500 labels" + "description": "Not found", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} } }, - "summary": "Export a printable QR label sheet as PDF", - "description": "Streams an A4 PDF of QR equipment labels laid out on an Avery L7159-compatible grid (63.5 x 33.9 mm, 3 columns x 8 rows, 24 labels per page). Each QR encodes the equipment IRI /api/equipment/{id}, the format the field scan resolves. Selection: ids[] (explicit equipment list) OR facilityId (all equipment of one facility) OR neither (the whole organization park); providing both is a 400. At most 500 labels per request — 422 beyond. Requires organization.equipment.read, resolved with the module's standard 403/404 split. Not plan-gated: labels are operational field material, not a reporting deliverable.", + "summary": "Removes the Equipment resource.", + "description": "Removes the Equipment resource.", "parameters": [ { - "name": "organizationId", + "name": "id", "in": "path", - "description": "EquipmentLabelSheet identifier", + "description": "Equipment identifier", "required": true, "deprecated": false, "schema": { @@ -11215,58 +11343,154 @@ }, "style": "simple", "explode": false + } + ] + }, + "patch": { + "operationId": "canonical_equipment_patch", + "tags": [ + "Equipment" + ], + "responses": { + "200": { + "description": "Equipment resource updated", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Equipment.EquipmentOutput.jsonld" + } + } + }, + "links": {} }, - { - "name": "ids[]", - "in": "query", - "description": "Explicit equipment identifiers to print, one label each. Mutually exclusive with facilityId.", - "required": false, - "deprecated": false, - "schema": { - "type": "array", - "items": { - "type": "string", - "format": "uuid" + "400": { + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } } }, - "style": "form", - "explode": true + "links": {} + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "404": { + "description": "Not found", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + } + }, + "summary": "Updates the Equipment resource.", + "description": "Updates the Equipment resource.", + "parameters": [ { - "name": "facilityId", - "in": "query", - "description": "Print one label for every equipment item of this facility. Mutually exclusive with ids[].", - "required": false, + "name": "id", + "in": "path", + "description": "Equipment identifier", + "required": true, "deprecated": false, "schema": { - "type": "string", - "format": "uuid" + "type": "string" }, - "style": "form", - "explode": true + "style": "simple", + "explode": false } ], - "security": [ - { - "bearerAuth": [] - } - ] + "requestBody": { + "description": "The updated Equipment resource", + "content": { + "application/merge-patch+json": { + "schema": { + "$ref": "#/components/schemas/Equipment.PatchCanonicalEquipmentInput.jsonMergePatch" + } + } + }, + "required": true + } } }, - "/api/organizations/{organizationId}/equipment/tags": { + "/api/organizations/{organizationId}/equipment": { "get": { - "operationId": "equipment_list_tags", + "operationId": "equipment_list", "tags": [ "Equipment" ], "responses": { "200": { - "description": "Tag catalog retrieved", + "description": "Equipment list retrieved", "content": { "application/ld+json": { "schema": { "type": "object", - "description": "EquipmentTag.TagOutput.jsonld-equipment.read collection.", + "description": "LegacyEquipment.EquipmentOutput.jsonld-equipment.read collection.", "allOf": [ { "$ref": "#/components/schemas/HydraCollectionBaseSchema" @@ -11280,7 +11504,7 @@ "member": { "type": "array", "items": { - "$ref": "#/components/schemas/EquipmentTag.TagOutput.jsonld-equipment.read" + "$ref": "#/components/schemas/LegacyEquipment.EquipmentOutput.jsonld-equipment.read" } } } @@ -11297,13 +11521,13 @@ "description": "Insufficient permissions" } }, - "summary": "List tag catalog", - "description": "Lists all tags available in the organization catalog. Supports optional search for typeahead.", + "summary": "List equipment", + "description": "Lists equipment items for the target organization.", "parameters": [ { "name": "organizationId", "in": "path", - "description": "EquipmentTag identifier", + "description": "LegacyEquipment identifier", "required": true, "deprecated": false, "schema": { @@ -11335,15 +11559,71 @@ "type": "integer", "default": 30, "minimum": 0, - "maximum": 100 + "maximum": 200 }, "style": "form", "explode": true }, { - "name": "search", + "name": "family", "in": "query", - "description": "Filter tags by name (case-insensitive substring match).", + "description": "LegacyEquipment family", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "enum": [ + "fire", + "safety", + "other" + ] + }, + "style": "form", + "explode": true + }, + { + "name": "customerId", + "in": "query", + "description": "LegacyEquipment customerId", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "uuid" + }, + "style": "form", + "explode": true + }, + { + "name": "includeDescendants", + "in": "query", + "description": "Include equipment assigned to published descendants of the facility filter.", + "required": false, + "deprecated": false, + "schema": { + "type": "boolean", + "default": false + }, + "style": "form", + "explode": true + }, + { + "name": "facilityId", + "in": "query", + "description": "Filter by facility identifier.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "uuid" + }, + "style": "form", + "explode": true + }, + { + "name": "type", + "in": "query", + "description": "Filter by equipment type.", "required": false, "deprecated": false, "schema": { @@ -11351,74 +11631,84 @@ }, "style": "form", "explode": true - } - ] - } - }, - "/api/organizations/{organizationId}/equipment/{equipmentId}": { - "get": { - "operationId": "equipment_get", - "tags": [ - "Equipment" - ], - "responses": { - "200": { - "description": "Equipment retrieved", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/LegacyEquipment.EquipmentOutput.jsonld-equipment.read" - } - } - } }, - "400": { - "description": "Invalid identifier" + { + "name": "status", + "in": "query", + "description": "Filter by equipment status.", + "required": false, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "form", + "explode": true }, - "403": { - "description": "Insufficient permissions" + { + "name": "brand", + "in": "query", + "description": "Filter by exact equipment brand.", + "required": false, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "form", + "explode": true }, - "404": { - "description": "Equipment not found" - } - }, - "summary": "Get equipment", - "description": "Returns one equipment item by identifier.", - "parameters": [ { - "name": "organizationId", - "in": "path", - "description": "LegacyEquipment identifier", - "required": true, + "name": "model", + "in": "query", + "description": "Filter by exact equipment model.", + "required": false, "deprecated": false, "schema": { "type": "string" }, - "style": "simple", - "explode": false + "style": "form", + "explode": true }, { - "name": "equipmentId", - "in": "path", - "description": "LegacyEquipment identifier", - "required": true, + "name": "subType", + "in": "query", + "description": "Filter by exact equipment subtype.", + "required": false, "deprecated": false, "schema": { "type": "string" }, - "style": "simple", - "explode": false + "style": "form", + "explode": true + }, + { + "name": "maintenanceDueStatus", + "in": "query", + "description": "Filter by maintenance due status (`unscheduled`, `up_to_date`, `due_soon`, `overdue`); `due` combines due-soon and overdue before pagination.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "enum": [ + "unscheduled", + "up_to_date", + "due_soon", + "overdue", + "due" + ] + }, + "style": "form", + "explode": true } ] }, - "patch": { - "operationId": "equipment_update", + "post": { + "operationId": "equipment_create", "tags": [ "Equipment" ], "responses": { - "200": { - "description": "Equipment updated", + "201": { + "description": "Equipment created", "content": { "application/ld+json": { "schema": { @@ -11452,9 +11742,6 @@ "403": { "description": "Insufficient permissions" }, - "404": { - "description": "Equipment not found" - }, "409": { "description": "Serial number already exists in this organization" }, @@ -11480,8 +11767,8 @@ "links": {} } }, - "summary": "Patch equipment", - "description": "Partially updates equipment information.", + "summary": "Create equipment", + "description": "Creates a new equipment item in the organization stock.", "parameters": [ { "name": "organizationId", @@ -11494,11 +11781,52 @@ }, "style": "simple", "explode": false + } + ], + "requestBody": { + "description": "The new LegacyEquipment resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/LegacyEquipment.CreateEquipmentInput-equipment.write" + } + } + }, + "required": true + } + } + }, + "/api/organizations/{organizationId}/equipment-summary": { + "get": { + "operationId": "organization_equipment_summary_get", + "tags": [ + "Equipment" + ], + "responses": { + "200": { + "description": "Equipment totals retrieved", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/OrganizationEquipmentSummary.FacilityEquipmentSummaryOutput.jsonld" + } + } + } + }, + "403": { + "description": "Missing equipment read permission" }, + "404": { + "description": "Organization or customer not found" + } + }, + "summary": "Get exact organization equipment totals", + "description": "Get exact organization equipment totals", + "parameters": [ { - "name": "equipmentId", + "name": "organizationId", "in": "path", - "description": "LegacyEquipment identifier", + "description": "OrganizationEquipmentSummary identifier", "required": true, "deprecated": false, "schema": { @@ -11506,34 +11834,113 @@ }, "style": "simple", "explode": false + }, + { + "name": "family", + "in": "query", + "description": "OrganizationEquipmentSummary family", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "enum": [ + "fire", + "safety", + "other" + ] + }, + "style": "form", + "explode": true + }, + { + "name": "customerId", + "in": "query", + "description": "OrganizationEquipmentSummary customerId", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "uuid" + }, + "style": "form", + "explode": true } + ] + } + }, + "/api/organizations/{organizationId}/equipment-types": { + "get": { + "operationId": "equipment_type_catalog_list", + "tags": [ + "Equipment" ], - "requestBody": { - "description": "The updated LegacyEquipment resource", - "content": { - "application/merge-patch+json": { - "schema": { - "$ref": "#/components/schemas/LegacyEquipment.UpdateEquipmentInput-equipment.write.jsonMergePatch" + "responses": { + "200": { + "description": "Catalog including archived descriptors", + "content": { + "application/ld+json": { + "schema": { + "type": "object", + "description": "EquipmentType.EquipmentTypeOutput.jsonld-equipment_type.read collection.", + "allOf": [ + { + "$ref": "#/components/schemas/HydraCollectionBaseSchemaNoPagination" + }, + { + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EquipmentType.EquipmentTypeOutput.jsonld-equipment_type.read" + } + } + } + } + ] + } } } }, - "required": true - } - } - }, - "/api/organizations/{organizationId}/equipment/{equipmentId}/assign": { + "403": { + "description": "Missing equipment-read permission" + }, + "404": { + "description": "Organization outside caller scope" + } + }, + "summary": "List organization equipment types, including archived types", + "description": "List organization equipment types, including archived types", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "EquipmentType identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ] + }, "post": { - "operationId": "equipment_assign_to_facility", + "operationId": "equipment_type_catalog_create", "tags": [ "Equipment" ], "responses": { - "200": { - "description": "Equipment assigned", + "201": { + "description": "Active equipment type created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/LegacyEquipment.EquipmentOutput.jsonld-equipment.read" + "$ref": "#/components/schemas/EquipmentType.EquipmentTypeOutput.jsonld-equipment_type.read" } } }, @@ -11561,13 +11968,16 @@ "links": {} }, "403": { - "description": "Insufficient permissions" + "description": "Missing equipment-write permission" }, "404": { - "description": "Equipment not found" + "description": "Organization outside caller scope" + }, + "409": { + "description": "Type code already exists" }, "422": { - "description": "An error occurred", + "description": "Invalid code, label or family", "content": { "application/ld+json": { "schema": { @@ -11588,25 +11998,13 @@ "links": {} } }, - "summary": "Assign to facility", - "description": "Assigns the equipment to a facility.", + "summary": "Create an organization equipment type", + "description": "Create an organization equipment type", "parameters": [ { "name": "organizationId", "in": "path", - "description": "LegacyEquipment identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - }, - { - "name": "equipmentId", - "in": "path", - "description": "LegacyEquipment identifier", + "description": "EquipmentType identifier", "required": true, "deprecated": false, "schema": { @@ -11617,11 +12015,11 @@ } ], "requestBody": { - "description": "The new LegacyEquipment resource", + "description": "The new EquipmentType resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/LegacyEquipment.AssignToFacilityInput-equipment.write" + "$ref": "#/components/schemas/EquipmentType.CreateEquipmentTypeInput-equipment_type.write" } } }, @@ -11629,60 +12027,37 @@ } } }, - "/api/organizations/{organizationId}/equipment/{equipmentId}/attachments": { + "/api/organizations/{organizationId}/equipment-types/{typeCode}": { "get": { - "operationId": "equipment_attachments_list", + "operationId": "equipment_type_catalog_get", "tags": [ "Equipment" ], "responses": { "200": { - "description": "Attachments retrieved", + "description": "Equipment type descriptor", "content": { "application/ld+json": { "schema": { - "type": "object", - "description": "EquipmentAttachment.AttachmentOutput.jsonld-equipment.read collection.", - "allOf": [ - { - "$ref": "#/components/schemas/HydraCollectionBaseSchema" - }, - { - "type": "object", - "required": [ - "member" - ], - "properties": { - "member": { - "type": "array", - "items": { - "$ref": "#/components/schemas/EquipmentAttachment.AttachmentOutput.jsonld-equipment.read" - } - } - } - } - ] + "$ref": "#/components/schemas/EquipmentType.EquipmentTypeOutput.jsonld-equipment_type.read" } } } }, - "400": { - "description": "Invalid identifier" - }, "403": { - "description": "Insufficient permissions" + "description": "Missing equipment-read permission" }, "404": { - "description": "Equipment not found" + "description": "Organization outside caller scope or unknown equipment type" } }, - "summary": "List attachments", - "description": "Lists all attachments for a given equipment item.", + "summary": "Read an organization equipment type", + "description": "Read an organization equipment type", "parameters": [ { "name": "organizationId", "in": "path", - "description": "EquipmentAttachment identifier", + "description": "EquipmentType identifier", "required": true, "deprecated": false, "schema": { @@ -11692,9 +12067,9 @@ "explode": false }, { - "name": "equipmentId", + "name": "typeCode", "in": "path", - "description": "EquipmentAttachment identifier", + "description": "EquipmentType identifier", "required": true, "deprecated": false, "schema": { @@ -11702,34 +12077,21 @@ }, "style": "simple", "explode": false - }, - { - "name": "page", - "in": "query", - "description": "The collection page number", - "required": false, - "deprecated": false, - "schema": { - "type": "integer", - "default": 1 - }, - "style": "form", - "explode": true } ] }, - "post": { - "operationId": "equipment_add_attachment", + "patch": { + "operationId": "equipment_type_catalog_patch", "tags": [ "Equipment" ], "responses": { - "201": { - "description": "Attachment uploaded", + "200": { + "description": "Equipment type updated", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/EquipmentAttachment.AttachmentOutput.jsonld-equipment.read" + "$ref": "#/components/schemas/EquipmentType.EquipmentTypeOutput.jsonld-equipment_type.read" } } }, @@ -11757,13 +12119,16 @@ "links": {} }, "403": { - "description": "Insufficient permissions" + "description": "Missing equipment-write permission" }, "404": { - "description": "Equipment not found" + "description": "Organization outside caller scope or unknown equipment type" + }, + "412": { + "description": "Observed descriptor revision is stale" }, "422": { - "description": "An error occurred", + "description": "Invalid label, family or revision", "content": { "application/ld+json": { "schema": { @@ -11784,13 +12149,13 @@ "links": {} } }, - "summary": "Add attachment", - "description": "Uploads a file attachment to the equipment (base64-encoded content in JSON).", + "summary": "Update or archive a type using its observed revision", + "description": "Update or archive a type using its observed revision", "parameters": [ { "name": "organizationId", "in": "path", - "description": "EquipmentAttachment identifier", + "description": "EquipmentType identifier", "required": true, "deprecated": false, "schema": { @@ -11800,9 +12165,9 @@ "explode": false }, { - "name": "equipmentId", + "name": "typeCode", "in": "path", - "description": "EquipmentAttachment identifier", + "description": "EquipmentType identifier", "required": true, "deprecated": false, "schema": { @@ -11813,11 +12178,11 @@ } ], "requestBody": { - "description": "The new EquipmentAttachment resource", + "description": "The updated EquipmentType resource", "content": { - "application/ld+json": { + "application/merge-patch+json": { "schema": { - "$ref": "#/components/schemas/EquipmentAttachment.AddAttachmentInput-equipment.write" + "$ref": "#/components/schemas/EquipmentType.PatchEquipmentTypeInput-equipment_type.write.jsonMergePatch" } } }, @@ -11825,33 +12190,39 @@ } } }, - "/api/organizations/{organizationId}/equipment/{equipmentId}/attachments/{attachmentId}": { - "delete": { - "operationId": "equipment_delete_attachment", + "/api/organizations/{organizationId}/equipment/export": { + "get": { + "operationId": "equipment_export", "tags": [ "Equipment" ], "responses": { + "200": { + "description": "CSV export streamed successfully" + }, "204": { - "description": "Attachment deleted" + "description": "EquipmentExport resource" }, "400": { - "description": "Invalid identifier" + "description": "Missing organizationId URI parameter" }, "403": { - "description": "Insufficient permissions" + "description": "Authenticated but missing organization.equipment.read" }, "404": { - "description": "Equipment or attachment not found" + "description": "The organization is outside the caller's scope" + }, + "422": { + "description": "Export exceeds the row cap" } }, - "summary": "Delete attachment", - "description": "Deletes an attachment from the equipment.", + "summary": "Export equipment (CSV)", + "description": "Streams a CSV export of every equipment item (Content-Disposition: attachment) for the given organization. Requires `organization.equipment.read` on the organization, resolved the same way the list endpoint resolves it — a resource-level ROLE_USER check alone does not grant access. The first six columns (type, subType, brand, model, serialNumber, locationLabel) are the import round-trip contract read back by the bulk CSV import; the remaining columns are read-only metadata. Bounded to 50000 matching rows — the request is rejected with 422 if the organization has more.", "parameters": [ { "name": "organizationId", "in": "path", - "description": "EquipmentAttachment identifier", + "description": "EquipmentExport identifier", "required": true, "deprecated": false, "schema": { @@ -11859,11 +12230,67 @@ }, "style": "simple", "explode": false + } + ], + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/api/organizations/{organizationId}/equipment/kpis": { + "get": { + "operationId": "equipment_get_kpis", + "tags": [ + "Equipment" + ], + "responses": { + "200": { + "description": "Equipment KPIs retrieved", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/EquipmentKpi.EquipmentKpiOutput.jsonld-equipment.read" + } + } + } + }, + "400": { + "description": "Invalid organization identifier" + }, + "403": { + "description": "Insufficient permissions" }, + "404": { + "description": "Not found", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + } + }, + "summary": "Get equipment KPIs", + "description": "Returns the four headline counters for the organization equipment overview: `totalAssets` (every status), `compliant` (cross-module maintenance due status `up_to_date`), `dueSoon` (cross-module maintenance due status `due_soon`), and `openNonConformities`. The last counter is deliberately ORGANIZATION-WIDE, not equipment-scoped: non-conformities attach to inspections, not to equipment, so there is no reliable per-equipment open-non-conformity count (see `src/Equipment/MODULE.md`).", + "parameters": [ { - "name": "equipmentId", + "name": "organizationId", "in": "path", - "description": "EquipmentAttachment identifier", + "description": "EquipmentKpi identifier", "required": true, "deprecated": false, "schema": { @@ -11871,11 +12298,43 @@ }, "style": "simple", "explode": false + } + ] + } + }, + "/api/organizations/{organizationId}/equipment/labels": { + "get": { + "operationId": "equipment_export_labels", + "tags": [ + "Equipment" + ], + "responses": { + "200": { + "description": "QR label sheet PDF" + }, + "204": { + "description": "EquipmentLabelSheet resource" + }, + "400": { + "description": "Missing organizationId, both ids[] and facilityId provided, or empty ids[]" + }, + "403": { + "description": "Missing organization.equipment.read permission" + }, + "404": { + "description": "Organization outside the caller's scope" }, + "422": { + "description": "Selection matches more than 500 labels" + } + }, + "summary": "Export a printable QR label sheet as PDF", + "description": "Streams an A4 PDF of QR equipment labels laid out on an Avery L7159-compatible grid (63.5 x 33.9 mm, 3 columns x 8 rows, 24 labels per page). Each QR encodes the equipment IRI /api/equipment/{id}, the format the field scan resolves. Selection: ids[] (explicit equipment list) OR facilityId (all equipment of one facility) OR neither (the whole organization park); providing both is a 400. At most 500 labels per request — 422 beyond. Requires organization.equipment.read, resolved with the module's standard 403/404 split. Not plan-gated: labels are operational field material, not a reporting deliverable.", + "parameters": [ { - "name": "attachmentId", + "name": "organizationId", "in": "path", - "description": "EquipmentAttachment identifier", + "description": "EquipmentLabelSheet identifier", "required": true, "deprecated": false, "schema": { @@ -11883,37 +12342,95 @@ }, "style": "simple", "explode": false + }, + { + "name": "ids[]", + "in": "query", + "description": "Explicit equipment identifiers to print, one label each. Mutually exclusive with facilityId.", + "required": false, + "deprecated": false, + "schema": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + } + }, + "style": "form", + "explode": true + }, + { + "name": "facilityId", + "in": "query", + "description": "Print one label for every equipment item of this facility. Mutually exclusive with ids[].", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "uuid" + }, + "style": "form", + "explode": true + } + ], + "security": [ + { + "bearerAuth": [] } ] } }, - "/api/organizations/{organizationId}/equipment/{equipmentId}/attachments/{attachmentId}/download": { + "/api/organizations/{organizationId}/equipment/tags": { "get": { - "operationId": "equipment_download_attachment", + "operationId": "equipment_list_tags", "tags": [ "Equipment" ], "responses": { "200": { - "description": "Attachment file content" + "description": "Tag catalog retrieved", + "content": { + "application/ld+json": { + "schema": { + "type": "object", + "description": "EquipmentTag.TagOutput.jsonld-equipment.read collection.", + "allOf": [ + { + "$ref": "#/components/schemas/HydraCollectionBaseSchema" + }, + { + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EquipmentTag.TagOutput.jsonld-equipment.read" + } + } + } + } + ] + } + } + } }, - "204": { - "description": "EquipmentAttachmentContent resource" + "400": { + "description": "Invalid organization identifier" }, "403": { - "description": "Insufficient permissions (requires organization.equipment.read)" - }, - "404": { - "description": "Organization, equipment, attachment, or its stored content not found" + "description": "Insufficient permissions" } }, - "summary": "Download an equipment attachment", - "description": "Streams the stored file bytes with Content-Disposition: attachment and X-Content-Type-Options: nosniff. Gated by organization.equipment.read.", + "summary": "List tag catalog", + "description": "Lists all tags available in the organization catalog. Supports optional search for typeahead.", "parameters": [ { "name": "organizationId", "in": "path", - "description": "EquipmentAttachmentContent identifier", + "description": "EquipmentTag identifier", "required": true, "deprecated": false, "schema": { @@ -11923,104 +12440,77 @@ "explode": false }, { - "name": "equipmentId", - "in": "path", - "description": "EquipmentAttachmentContent identifier", - "required": true, + "name": "page", + "in": "query", + "description": "The collection page number", + "required": false, "deprecated": false, "schema": { - "type": "string" + "type": "integer", + "default": 1 }, - "style": "simple", - "explode": false + "style": "form", + "explode": true }, { - "name": "attachmentId", - "in": "path", - "description": "EquipmentAttachmentContent identifier", - "required": true, + "name": "itemsPerPage", + "in": "query", + "description": "The number of items per page", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 30, + "minimum": 0, + "maximum": 100 + }, + "style": "form", + "explode": true + }, + { + "name": "search", + "in": "query", + "description": "Filter tags by name (case-insensitive substring match).", + "required": false, "deprecated": false, "schema": { "type": "string" }, - "style": "simple", - "explode": false + "style": "form", + "explode": true } ] } }, - "/api/organizations/{organizationId}/equipment/{equipmentId}/commission": { - "post": { - "operationId": "equipment_commission", + "/api/organizations/{organizationId}/equipment/{equipmentId}": { + "get": { + "operationId": "equipment_get", "tags": [ "Equipment" ], "responses": { "200": { - "description": "Equipment commissioned", + "description": "Equipment retrieved", "content": { "application/ld+json": { "schema": { "$ref": "#/components/schemas/LegacyEquipment.EquipmentOutput.jsonld-equipment.read" } } - }, - "links": {} + } }, "400": { - "description": "Invalid identifier or equipment not assigned to a facility", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "links": {} + "description": "Invalid identifier" }, "403": { "description": "Insufficient permissions" }, "404": { "description": "Equipment not found" - }, - "409": { - "description": "Equipment already decommissioned" - }, - "422": { - "description": "An error occurred", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" - } - } - }, - "links": {} } }, - "summary": "Commission equipment", - "description": "Marks the equipment as commissioned and operational.", + "summary": "Get equipment", + "description": "Returns one equipment item by identifier.", "parameters": [ { "name": "organizationId", @@ -12046,19 +12536,16 @@ "style": "simple", "explode": false } - ], - "deprecated": true - } - }, - "/api/organizations/{organizationId}/equipment/{equipmentId}/decommission": { - "post": { - "operationId": "equipment_decommission", + ] + }, + "patch": { + "operationId": "equipment_update", "tags": [ "Equipment" ], "responses": { "200": { - "description": "Equipment decommissioned", + "description": "Equipment updated", "content": { "application/ld+json": { "schema": { @@ -12069,7 +12556,7 @@ "links": {} }, "400": { - "description": "Invalid identifier", + "description": "Invalid input", "content": { "application/ld+json": { "schema": { @@ -12096,7 +12583,7 @@ "description": "Equipment not found" }, "409": { - "description": "Equipment already decommissioned" + "description": "Serial number already exists in this organization" }, "422": { "description": "An error occurred", @@ -12120,8 +12607,8 @@ "links": {} } }, - "summary": "Decommission equipment", - "description": "Permanently decommissions the equipment.", + "summary": "Patch equipment", + "description": "Partially updates equipment information.", "parameters": [ { "name": "organizationId", @@ -12147,18 +12634,29 @@ "style": "simple", "explode": false } - ] + ], + "requestBody": { + "description": "The updated LegacyEquipment resource", + "content": { + "application/merge-patch+json": { + "schema": { + "$ref": "#/components/schemas/LegacyEquipment.UpdateEquipmentInput-equipment.write.jsonMergePatch" + } + } + }, + "required": true + } } }, - "/api/organizations/{organizationId}/equipment/{equipmentId}/maintenance": { + "/api/organizations/{organizationId}/equipment/{equipmentId}/assign": { "post": { - "operationId": "equipment_maintenance", + "operationId": "equipment_assign_to_facility", "tags": [ "Equipment" ], "responses": { "200": { - "description": "Equipment put under maintenance", + "description": "Equipment assigned", "content": { "application/ld+json": { "schema": { @@ -12169,7 +12667,7 @@ "links": {} }, "400": { - "description": "Invalid identifier or equipment not assigned to a facility", + "description": "Invalid input", "content": { "application/ld+json": { "schema": { @@ -12195,9 +12693,6 @@ "404": { "description": "Equipment not found" }, - "409": { - "description": "Equipment already decommissioned" - }, "422": { "description": "An error occurred", "content": { @@ -12220,8 +12715,8 @@ "links": {} } }, - "summary": "Put under maintenance", - "description": "Marks the equipment as under maintenance.", + "summary": "Assign to facility", + "description": "Assigns the equipment to a facility.", "parameters": [ { "name": "organizationId", @@ -12247,23 +12742,34 @@ "style": "simple", "explode": false } - ] + ], + "requestBody": { + "description": "The new LegacyEquipment resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/LegacyEquipment.AssignToFacilityInput-equipment.write" + } + } + }, + "required": true + } } }, - "/api/organizations/{organizationId}/equipment/{equipmentId}/maintenance-logs": { + "/api/organizations/{organizationId}/equipment/{equipmentId}/attachments": { "get": { - "operationId": "equipment_list_maintenance_logs", + "operationId": "equipment_attachments_list", "tags": [ "Equipment" ], "responses": { "200": { - "description": "Maintenance logs retrieved", + "description": "Attachments retrieved", "content": { "application/ld+json": { "schema": { "type": "object", - "description": "EquipmentMaintenanceLog.MaintenanceLogOutput.jsonld-equipment.read collection.", + "description": "EquipmentAttachment.AttachmentOutput.jsonld-equipment.read collection.", "allOf": [ { "$ref": "#/components/schemas/HydraCollectionBaseSchema" @@ -12277,7 +12783,7 @@ "member": { "type": "array", "items": { - "$ref": "#/components/schemas/EquipmentMaintenanceLog.MaintenanceLogOutput.jsonld-equipment.read" + "$ref": "#/components/schemas/EquipmentAttachment.AttachmentOutput.jsonld-equipment.read" } } } @@ -12297,13 +12803,13 @@ "description": "Equipment not found" } }, - "summary": "List maintenance logs", - "description": "Lists the maintenance windows recorded for a given equipment item, ordered by start date descending.", + "summary": "List attachments", + "description": "Lists all attachments for a given equipment item.", "parameters": [ { "name": "organizationId", "in": "path", - "description": "EquipmentMaintenanceLog identifier", + "description": "EquipmentAttachment identifier", "required": true, "deprecated": false, "schema": { @@ -12315,7 +12821,7 @@ { "name": "equipmentId", "in": "path", - "description": "EquipmentMaintenanceLog identifier", + "description": "EquipmentAttachment identifier", "required": true, "deprecated": false, "schema": { @@ -12336,38 +12842,21 @@ }, "style": "form", "explode": true - }, - { - "name": "itemsPerPage", - "in": "query", - "description": "The number of items per page", - "required": false, - "deprecated": false, - "schema": { - "type": "integer", - "default": 20, - "minimum": 0, - "maximum": 100 - }, - "style": "form", - "explode": true } ] - } - }, - "/api/organizations/{organizationId}/equipment/{equipmentId}/plan-position": { - "put": { - "operationId": "equipment_plan_position_set", + }, + "post": { + "operationId": "equipment_add_attachment", "tags": [ "Equipment" ], "responses": { - "200": { - "description": "Plan position set or cleared", + "201": { + "description": "Attachment uploaded", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/LegacyEquipment.EquipmentOutput.jsonld-equipment.read" + "$ref": "#/components/schemas/EquipmentAttachment.AttachmentOutput.jsonld-equipment.read" } } }, @@ -12398,10 +12887,7 @@ "description": "Insufficient permissions" }, "404": { - "description": "Equipment or attachment not found" - }, - "409": { - "description": "Equipment not assigned to a facility, already decommissioned, or attachment is not a floor plan / not an ancestor" + "description": "Equipment not found" }, "422": { "description": "An error occurred", @@ -12425,13 +12911,13 @@ "links": {} } }, - "summary": "Set equipment plan position", - "description": "Pins the equipment at a point over a floor plan attachment belonging to its own facility or one of its ancestors. Submit \"attachmentId\", \"x\" and \"y\" (normalized 0-1 coordinates) together to set or replace the position, or all three null to clear it. The equipment must be assigned to a facility.", + "summary": "Add attachment", + "description": "Uploads a file attachment to the equipment (base64-encoded content in JSON).", "parameters": [ { "name": "organizationId", "in": "path", - "description": "LegacyEquipment identifier", + "description": "EquipmentAttachment identifier", "required": true, "deprecated": false, "schema": { @@ -12443,7 +12929,7 @@ { "name": "equipmentId", "in": "path", - "description": "LegacyEquipment identifier", + "description": "EquipmentAttachment identifier", "required": true, "deprecated": false, "schema": { @@ -12454,11 +12940,11 @@ } ], "requestBody": { - "description": "The updated LegacyEquipment resource", + "description": "The new EquipmentAttachment resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/LegacyEquipment.SetEquipmentPlanPositionInput-equipment.write" + "$ref": "#/components/schemas/EquipmentAttachment.AddAttachmentInput-equipment.write" } } }, @@ -12466,36 +12952,33 @@ } } }, - "/api/organizations/{organizationId}/equipment/{equipmentId}/report": { - "get": { - "operationId": "equipment_export_report", + "/api/organizations/{organizationId}/equipment/{equipmentId}/attachments/{attachmentId}": { + "delete": { + "operationId": "equipment_delete_attachment", "tags": [ "Equipment" ], "responses": { - "200": { - "description": "Equipment sheet PDF" - }, "204": { - "description": "EquipmentReportExport resource" + "description": "Attachment deleted" }, "400": { - "description": "Missing organizationId or equipmentId URI parameter" + "description": "Invalid identifier" }, "403": { - "description": "Missing organization.equipment.read, or plan not entitled (pro/max required)" + "description": "Insufficient permissions" }, "404": { - "description": "Organization outside the caller's scope, or equipment not found" + "description": "Equipment or attachment not found" } }, - "summary": "Export an equipment sheet as PDF", - "description": "Streams a PDF sheet of one equipment item (identity, maintenance history, attachment index) with Content-Disposition: attachment. Requires organization.equipment.read, resolved with the module's standard 403/404 split, AND a pro/max plan — the same entitlement gate as the regulatory safety register export.", + "summary": "Delete attachment", + "description": "Deletes an attachment from the equipment.", "parameters": [ { "name": "organizationId", "in": "path", - "description": "EquipmentReportExport identifier", + "description": "EquipmentAttachment identifier", "required": true, "deprecated": false, "schema": { @@ -12507,7 +12990,19 @@ { "name": "equipmentId", "in": "path", - "description": "EquipmentReportExport identifier", + "description": "EquipmentAttachment identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "attachmentId", + "in": "path", + "description": "EquipmentAttachment identifier", "required": true, "deprecated": false, "schema": { @@ -12516,34 +13011,91 @@ "style": "simple", "explode": false } + ] + } + }, + "/api/organizations/{organizationId}/equipment/{equipmentId}/attachments/{attachmentId}/download": { + "get": { + "operationId": "equipment_download_attachment", + "tags": [ + "Equipment" ], - "security": [ + "responses": { + "200": { + "description": "Attachment file content" + }, + "204": { + "description": "EquipmentAttachmentContent resource" + }, + "403": { + "description": "Insufficient permissions (requires organization.equipment.read)" + }, + "404": { + "description": "Organization, equipment, attachment, or its stored content not found" + } + }, + "summary": "Download an equipment attachment", + "description": "Streams the stored file bytes with Content-Disposition: attachment and X-Content-Type-Options: nosniff. Gated by organization.equipment.read.", + "parameters": [ { - "bearerAuth": [] + "name": "organizationId", + "in": "path", + "description": "EquipmentAttachmentContent identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "equipmentId", + "in": "path", + "description": "EquipmentAttachmentContent identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "attachmentId", + "in": "path", + "description": "EquipmentAttachmentContent identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false } ] } }, - "/api/organizations/{organizationId}/equipment/{equipmentId}/tags": { + "/api/organizations/{organizationId}/equipment/{equipmentId}/commission": { "post": { - "operationId": "equipment_add_tag", + "operationId": "equipment_commission", "tags": [ "Equipment" ], "responses": { - "201": { - "description": "Tag added", + "200": { + "description": "Equipment commissioned", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/EquipmentTag.TagOutput.jsonld-equipment.read" + "$ref": "#/components/schemas/LegacyEquipment.EquipmentOutput.jsonld-equipment.read" } } }, "links": {} }, "400": { - "description": "Invalid input", + "description": "Invalid identifier or equipment not assigned to a facility", "content": { "application/ld+json": { "schema": { @@ -12569,6 +13121,9 @@ "404": { "description": "Equipment not found" }, + "409": { + "description": "Equipment already decommissioned" + }, "422": { "description": "An error occurred", "content": { @@ -12591,13 +13146,13 @@ "links": {} } }, - "summary": "Add tag to equipment", - "description": "Adds a tag (created if not existing) to the equipment.", + "summary": "Commission equipment", + "description": "Marks the equipment as commissioned and operational.", "parameters": [ { "name": "organizationId", "in": "path", - "description": "EquipmentTag identifier", + "description": "LegacyEquipment identifier", "required": true, "deprecated": false, "schema": { @@ -12609,7 +13164,7 @@ { "name": "equipmentId", "in": "path", - "description": "EquipmentTag identifier", + "description": "LegacyEquipment identifier", "required": true, "deprecated": false, "schema": { @@ -12619,46 +13174,86 @@ "explode": false } ], - "requestBody": { - "description": "The new EquipmentTag resource", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/EquipmentTag.AddTagInput-equipment.write" - } - } - }, - "required": true - } + "deprecated": true } }, - "/api/organizations/{organizationId}/equipment/{equipmentId}/tags/{tagId}": { - "delete": { - "operationId": "equipment_remove_tag", + "/api/organizations/{organizationId}/equipment/{equipmentId}/decommission": { + "post": { + "operationId": "equipment_decommission", "tags": [ "Equipment" ], "responses": { - "204": { - "description": "Tag removed" + "200": { + "description": "Equipment decommissioned", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/LegacyEquipment.EquipmentOutput.jsonld-equipment.read" + } + } + }, + "links": {} }, "400": { - "description": "Invalid identifier" + "description": "Invalid identifier", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} }, "403": { "description": "Insufficient permissions" }, "404": { - "description": "Equipment or tag not found" + "description": "Equipment not found" + }, + "409": { + "description": "Equipment already decommissioned" + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} } }, - "summary": "Remove tag from equipment", - "description": "Removes a tag from the equipment.", + "summary": "Decommission equipment", + "description": "Permanently decommissions the equipment.", "parameters": [ { "name": "organizationId", "in": "path", - "description": "EquipmentTag identifier", + "description": "LegacyEquipment identifier", "required": true, "deprecated": false, "schema": { @@ -12670,19 +13265,7 @@ { "name": "equipmentId", "in": "path", - "description": "EquipmentTag identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - }, - { - "name": "tagId", - "in": "path", - "description": "EquipmentTag identifier", + "description": "LegacyEquipment identifier", "required": true, "deprecated": false, "schema": { @@ -12694,15 +13277,15 @@ ] } }, - "/api/organizations/{organizationId}/equipment/{equipmentId}/unassign": { + "/api/organizations/{organizationId}/equipment/{equipmentId}/maintenance": { "post": { - "operationId": "equipment_unassign_from_facility", + "operationId": "equipment_maintenance", "tags": [ "Equipment" ], "responses": { "200": { - "description": "Equipment unassigned", + "description": "Equipment put under maintenance", "content": { "application/ld+json": { "schema": { @@ -12713,7 +13296,7 @@ "links": {} }, "400": { - "description": "Invalid identifier", + "description": "Invalid identifier or equipment not assigned to a facility", "content": { "application/ld+json": { "schema": { @@ -12739,6 +13322,9 @@ "404": { "description": "Equipment not found" }, + "409": { + "description": "Equipment already decommissioned" + }, "422": { "description": "An error occurred", "content": { @@ -12761,8 +13347,8 @@ "links": {} } }, - "summary": "Unassign from facility", - "description": "Removes the equipment from its current facility.", + "summary": "Put under maintenance", + "description": "Marks the equipment as under maintenance.", "parameters": [ { "name": "organizationId", @@ -12791,20 +13377,20 @@ ] } }, - "/api/organizations/{organizationId}/facilities/{facilityId}/equipment": { + "/api/organizations/{organizationId}/equipment/{equipmentId}/maintenance-logs": { "get": { - "operationId": "facility_equipment_list", + "operationId": "equipment_list_maintenance_logs", "tags": [ "Equipment" ], "responses": { "200": { - "description": "Facility equipment list retrieved", + "description": "Maintenance logs retrieved", "content": { "application/ld+json": { "schema": { "type": "object", - "description": "LegacyEquipment.EquipmentOutput.jsonld-equipment.read collection.", + "description": "EquipmentMaintenanceLog.MaintenanceLogOutput.jsonld-equipment.read collection.", "allOf": [ { "$ref": "#/components/schemas/HydraCollectionBaseSchema" @@ -12818,7 +13404,7 @@ "member": { "type": "array", "items": { - "$ref": "#/components/schemas/LegacyEquipment.EquipmentOutput.jsonld-equipment.read" + "$ref": "#/components/schemas/EquipmentMaintenanceLog.MaintenanceLogOutput.jsonld-equipment.read" } } } @@ -12829,22 +13415,22 @@ } }, "400": { - "description": "Invalid organization or facility identifier" + "description": "Invalid identifier" }, "403": { "description": "Insufficient permissions" }, "404": { - "description": "Facility not found in the organization" + "description": "Equipment not found" } }, - "summary": "List facility equipment", - "description": "Lists directly assigned equipment, or the published facility subtree when includeDescendants is true. Search and pagination apply before the returned page.", + "summary": "List maintenance logs", + "description": "Lists the maintenance windows recorded for a given equipment item, ordered by start date descending.", "parameters": [ { "name": "organizationId", "in": "path", - "description": "LegacyEquipment identifier", + "description": "EquipmentMaintenanceLog identifier", "required": true, "deprecated": false, "schema": { @@ -12854,9 +13440,9 @@ "explode": false }, { - "name": "facilityId", + "name": "equipmentId", "in": "path", - "description": "LegacyEquipment identifier", + "description": "EquipmentMaintenanceLog identifier", "required": true, "deprecated": false, "schema": { @@ -12886,141 +13472,93 @@ "deprecated": false, "schema": { "type": "integer", - "default": 30, + "default": 20, "minimum": 0, "maximum": 100 }, "style": "form", "explode": true - }, - { - "name": "includeDescendants", - "in": "query", - "description": "Include equipment assigned to published descendants of this facility.", - "required": false, - "deprecated": false, - "schema": { - "type": "boolean", - "default": false - }, - "style": "form", - "explode": true - }, - { - "name": "type", - "in": "query", - "description": "Filter by equipment type.", - "required": false, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "form", - "explode": true - }, - { - "name": "status", - "in": "query", - "description": "Filter by equipment status.", - "required": false, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "form", - "explode": true - }, - { - "name": "brand", - "in": "query", - "description": "Filter by exact equipment brand.", - "required": false, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "form", - "explode": true - }, - { - "name": "model", - "in": "query", - "description": "Filter by exact equipment model.", - "required": false, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "form", - "explode": true - }, - { - "name": "subType", - "in": "query", - "description": "Filter by exact equipment subtype.", - "required": false, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "form", - "explode": true - }, - { - "name": "maintenanceDueStatus", - "in": "query", - "description": "Filter by cross-module maintenance due status (`unscheduled`, `up_to_date`, `due_soon`, `overdue`).", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "enum": [ - "unscheduled", - "up_to_date", - "due_soon", - "overdue" - ] - }, - "style": "form", - "explode": true } ] } }, - "/api/organizations/{organizationId}/facilities/{facilityId}/equipment-summary": { - "get": { - "operationId": "facility_equipment_summary_get", + "/api/organizations/{organizationId}/equipment/{equipmentId}/plan-position": { + "put": { + "operationId": "equipment_plan_position_set", "tags": [ "Equipment" ], "responses": { "200": { - "description": "Facility equipment totals retrieved", + "description": "Plan position set or cleared", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/FacilityEquipmentSummary.FacilityEquipmentSummaryOutput.jsonld" + "$ref": "#/components/schemas/LegacyEquipment.EquipmentOutput.jsonld-equipment.read" } } - } + }, + "links": {} }, "400": { - "description": "Invalid scope or identifiers" + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} }, "403": { - "description": "Missing organization.equipment.read permission" + "description": "Insufficient permissions" }, "404": { - "description": "Facility not found in the organization" + "description": "Equipment or attachment not found" + }, + "409": { + "description": "Equipment not assigned to a facility, already decommissioned, or attachment is not a floor plan / not an ancestor" + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} } }, - "summary": "Get exact facility equipment totals", - "description": "Counts published equipment in the same direct or subtree scope as the equipment collection, before pagination.", + "summary": "Set equipment plan position", + "description": "Pins the equipment at a point over a floor plan attachment belonging to its own facility or one of its ancestors. Submit \"attachmentId\", \"x\" and \"y\" (normalized 0-1 coordinates) together to set or replace the position, or all three null to clear it. The equipment must be assigned to a facility.", "parameters": [ { "name": "organizationId", "in": "path", - "description": "FacilityEquipmentSummary identifier", + "description": "LegacyEquipment identifier", "required": true, "deprecated": false, "schema": { @@ -13030,9 +13568,9 @@ "explode": false }, { - "name": "facilityId", + "name": "equipmentId", "in": "path", - "description": "FacilityEquipmentSummary identifier", + "description": "LegacyEquipment identifier", "required": true, "deprecated": false, "schema": { @@ -13040,62 +13578,41 @@ }, "style": "simple", "explode": false - }, - { - "name": "includeDescendants", - "in": "query", - "description": "FacilityEquipmentSummary includeDescendants", - "required": false, - "deprecated": false, - "schema": { - "type": "boolean", - "default": true - }, - "style": "form", - "explode": true } - ] + ], + "requestBody": { + "description": "The updated LegacyEquipment resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/LegacyEquipment.SetEquipmentPlanPositionInput-equipment.write" + } + } + }, + "required": true + } } }, - "/api/facilities": { - "get": { - "operationId": "api_facilities_get_collection", + "/api/organizations/{organizationId}/equipment/{equipmentId}/replace": { + "post": { + "operationId": "equipment_replace", "tags": [ - "Facility" + "Equipment" ], "responses": { "200": { - "description": "Facility collection", + "description": "Replacement committed or replayed", "content": { "application/ld+json": { "schema": { - "type": "object", - "description": "Facility.FacilityOutput.jsonld collection.", - "allOf": [ - { - "$ref": "#/components/schemas/HydraCollectionBaseSchema" - }, - { - "type": "object", - "required": [ - "member" - ], - "properties": { - "member": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Facility.FacilityOutput.jsonld" - } - } - } - } - ] + "$ref": "#/components/schemas/EquipmentReplacement.ReplaceEquipmentOutput.jsonld-equipment.read" } } - } + }, + "links": {} }, - "403": { - "description": "Forbidden", + "400": { + "description": "Invalid input", "content": { "application/ld+json": { "schema": { @@ -13114,106 +13631,150 @@ } }, "links": {} + }, + "403": { + "description": "Missing equipment write permission" + }, + "404": { + "description": "Organization or equipment unavailable" + }, + "409": { + "description": "Replacement lifecycle or replay conflict" + }, + "422": { + "description": "Invalid successor or operation identity", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} } }, - "summary": "Retrieves the collection of Facility resources.", - "description": "Retrieves the collection of Facility resources.", + "summary": "Replace equipment", + "description": "Retires a published asset and transfers its placement to an unused stock asset or atomically creates its successor. Replays return the original result.", "parameters": [ { - "name": "page", - "in": "query", - "description": "The collection page number", - "required": false, + "name": "organizationId", + "in": "path", + "description": "EquipmentReplacement identifier", + "required": true, "deprecated": false, "schema": { - "type": "integer", - "default": 1 + "type": "string" }, - "style": "form", - "explode": true + "style": "simple", + "explode": false }, { - "name": "itemsPerPage", - "in": "query", - "description": "The number of items per page", - "required": false, - "deprecated": false, - "schema": { - "type": "integer", - "default": 50, - "minimum": 0, - "maximum": 100 - }, - "style": "form", - "explode": true - }, - { - "name": "includePath", - "in": "query", - "description": "Resolve ancestor breadcrumbs for this page in one organization-scoped read.", - "required": false, + "name": "equipmentId", + "in": "path", + "description": "EquipmentReplacement identifier", + "required": true, "deprecated": false, "schema": { - "type": "boolean", - "default": false + "type": "string" }, - "style": "form", - "explode": true + "style": "simple", + "explode": false + } + ], + "requestBody": { + "description": "The new EquipmentReplacement resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/EquipmentReplacement.ReplaceEquipmentInput-equipment.write" + } + } + }, + "required": true + } + } + }, + "/api/organizations/{organizationId}/equipment/{equipmentId}/report": { + "get": { + "operationId": "equipment_export_report", + "tags": [ + "Equipment" + ], + "responses": { + "200": { + "description": "Equipment sheet PDF" + }, + "204": { + "description": "EquipmentReportExport resource" }, + "400": { + "description": "Missing organizationId or equipmentId URI parameter" + }, + "403": { + "description": "Missing organization.equipment.read, or plan not entitled (pro/max required)" + }, + "404": { + "description": "Organization outside the caller's scope, or equipment not found" + } + }, + "summary": "Export an equipment sheet as PDF", + "description": "Streams a PDF sheet of one equipment item (identity, maintenance history, attachment index) with Content-Disposition: attachment. Requires organization.equipment.read, resolved with the module's standard 403/404 split, AND a pro/max plan — the same entitlement gate as the regulatory safety register export.", + "parameters": [ { - "name": "organization", - "in": "query", - "description": "Organization IRI. Required when intervention is omitted.", - "required": false, + "name": "organizationId", + "in": "path", + "description": "EquipmentReportExport identifier", + "required": true, "deprecated": false, "schema": { "type": "string" }, - "style": "form", - "explode": true + "style": "simple", + "explode": false }, { - "name": "intervention", - "in": "query", - "description": "Intervention IRI. Also scopes the organization and defaults recordStatus to draft.", - "required": false, + "name": "equipmentId", + "in": "path", + "description": "EquipmentReportExport identifier", + "required": true, "deprecated": false, "schema": { "type": "string" }, - "style": "form", - "explode": true - }, + "style": "simple", + "explode": false + } + ], + "security": [ { - "name": "recordStatus", - "in": "query", - "description": "Lifecycle status of the representation.", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "enum": [ - "draft", - "published" - ] - }, - "style": "form", - "explode": true + "bearerAuth": [] } ] - }, + } + }, + "/api/organizations/{organizationId}/equipment/{equipmentId}/tags": { "post": { - "operationId": "api_facilities_post", + "operationId": "equipment_add_tag", "tags": [ - "Facility" + "Equipment" ], "responses": { "201": { - "description": "Facility resource created", + "description": "Tag added", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Facility.FacilityOutput.jsonld" + "$ref": "#/components/schemas/EquipmentTag.TagOutput.jsonld-equipment.read" } } }, @@ -13241,25 +13802,10 @@ "links": {} }, "403": { - "description": "Forbidden", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "links": {} + "description": "Insufficient permissions" + }, + "404": { + "description": "Equipment not found" }, "422": { "description": "An error occurred", @@ -13283,15 +13829,40 @@ "links": {} } }, - "summary": "Creates a Facility resource.", - "description": "Creates a Facility resource.", - "parameters": [], + "summary": "Add tag to equipment", + "description": "Adds a tag (created if not existing) to the equipment.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "EquipmentTag identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "equipmentId", + "in": "path", + "description": "EquipmentTag identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], "requestBody": { - "description": "The new Facility resource", + "description": "The new EquipmentTag resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Facility.CreateFacilityInput" + "$ref": "#/components/schemas/EquipmentTag.AddTagInput-equipment.write" } } }, @@ -13299,57 +13870,33 @@ } } }, - "/api/facilities/{facilityId}/attachments": { - "get": { - "operationId": "api_facilities_facilityIdattachments_get_collection", + "/api/organizations/{organizationId}/equipment/{equipmentId}/tags/{tagId}": { + "delete": { + "operationId": "equipment_remove_tag", "tags": [ - "Facility" + "Equipment" ], "responses": { - "200": { - "description": "Attachments retrieved", - "content": { - "application/ld+json": { - "schema": { - "type": "object", - "description": "FacilityAttachment.FacilityAttachmentOutput.jsonld-facility.read collection.", - "allOf": [ - { - "$ref": "#/components/schemas/HydraCollectionBaseSchema" - }, - { - "type": "object", - "required": [ - "member" - ], - "properties": { - "member": { - "type": "array", - "items": { - "$ref": "#/components/schemas/FacilityAttachment.FacilityAttachmentOutput.jsonld-facility.read" - } - } - } - } - ] - } - } - } + "204": { + "description": "Tag removed" + }, + "400": { + "description": "Invalid identifier" }, "403": { "description": "Insufficient permissions" }, "404": { - "description": "Facility not found" + "description": "Equipment or tag not found" } }, - "summary": "List facility attachments", - "description": "Lists all attachments for a given facility.", + "summary": "Remove tag from equipment", + "description": "Removes a tag from the equipment.", "parameters": [ { - "name": "facilityId", + "name": "organizationId", "in": "path", - "description": "FacilityAttachment identifier", + "description": "EquipmentTag identifier", "required": true, "deprecated": false, "schema": { @@ -13359,55 +13906,52 @@ "explode": false }, { - "name": "page", - "in": "query", - "description": "The collection page number", - "required": false, + "name": "equipmentId", + "in": "path", + "description": "EquipmentTag identifier", + "required": true, "deprecated": false, "schema": { - "type": "integer", - "default": 1 + "type": "string" }, - "style": "form", - "explode": true + "style": "simple", + "explode": false }, { - "name": "kind", - "in": "query", - "description": "Filter by attachment kind (\"document\" or \"floor_plan\").", - "required": false, + "name": "tagId", + "in": "path", + "description": "EquipmentTag identifier", + "required": true, "deprecated": false, "schema": { - "type": "string", - "enum": [ - "document", - "floor_plan" - ] + "type": "string" }, - "style": "form", - "explode": true + "style": "simple", + "explode": false } ] - }, + } + }, + "/api/organizations/{organizationId}/equipment/{equipmentId}/unassign": { "post": { - "operationId": "api_facilities_facilityIdattachments_post", + "operationId": "equipment_unassign_from_facility", "tags": [ - "Facility" + "Equipment" ], "responses": { - "201": { - "description": "Attachment uploaded", + "200": { + "description": "Equipment unassigned", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/FacilityAttachment.FacilityAttachmentOutput.jsonld-facility.read" + "$ref": "#/components/schemas/LegacyEquipment.EquipmentOutput.jsonld-equipment.read" } } }, "links": {} }, "400": { - "description": "Invalid input", + "description": "Invalid identifier", "content": { "application/ld+json": { "schema": { @@ -13431,10 +13975,10 @@ "description": "Insufficient permissions" }, "404": { - "description": "Facility not found" + "description": "Equipment not found" }, "422": { - "description": "MIME type or size rejected", + "description": "An error occurred", "content": { "application/ld+json": { "schema": { @@ -13455,13 +13999,25 @@ "links": {} } }, - "summary": "Upload a facility attachment", - "description": "Uploads a multipart file attachment to a facility. An optional `kind` field (\"document\", the default, or \"floor_plan\") selects the attachment kind; a floor plan is restricted to image/png, image/jpeg, image/webp or image/svg+xml and has its pixel dimensions probed server-side.", + "summary": "Unassign from facility", + "description": "Removes the equipment from its current facility.", "parameters": [ { - "name": "facilityId", + "name": "organizationId", "in": "path", - "description": "FacilityAttachment identifier", + "description": "LegacyEquipment identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "equipmentId", + "in": "path", + "description": "LegacyEquipment identifier", "required": true, "deprecated": false, "schema": { @@ -13473,73 +14029,60 @@ ] } }, - "/api/facilities/{id}": { + "/api/organizations/{organizationId}/facilities/{facilityId}/equipment": { "get": { - "operationId": "api_facilities_id_get", + "operationId": "facility_equipment_list", "tags": [ - "Facility" + "Equipment" ], "responses": { "200": { - "description": "Facility resource", + "description": "Facility equipment list retrieved", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Facility.FacilityOutput.jsonld" + "type": "object", + "description": "LegacyEquipment.EquipmentOutput.jsonld-equipment.read collection.", + "allOf": [ + { + "$ref": "#/components/schemas/HydraCollectionBaseSchema" + }, + { + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LegacyEquipment.EquipmentOutput.jsonld-equipment.read" + } + } + } + } + ] } } } }, + "400": { + "description": "Invalid organization or facility identifier" + }, "403": { - "description": "Forbidden", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "links": {} + "description": "Insufficient permissions" }, "404": { - "description": "Not found", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "links": {} + "description": "Facility not found in the organization" } }, - "summary": "Retrieves a Facility resource.", - "description": "Retrieves a Facility resource.", + "summary": "List facility equipment", + "description": "Lists directly assigned equipment, or the published facility subtree when includeDescendants is true. Search and pagination apply before the returned page.", "parameters": [ { - "name": "id", + "name": "organizationId", "in": "path", - "description": "Facility identifier", + "description": "LegacyEquipment identifier", "required": true, "deprecated": false, "schema": { @@ -13547,118 +14090,11 @@ }, "style": "simple", "explode": false - } - ] - }, - "put": { - "operationId": "canonical_facility_put", - "tags": [ - "Facility" - ], - "responses": { - "201": { - "description": "Facility resource updated", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Facility.FacilityOutput.jsonld" - } - } - }, - "links": {} - }, - "400": { - "description": "Invalid input", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "links": {} - }, - "403": { - "description": "Forbidden", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "links": {} - }, - "404": { - "description": "Not found", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "links": {} }, - "422": { - "description": "An error occurred", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" - } - } - }, - "links": {} - } - }, - "summary": "Replaces the Facility resource.", - "description": "Replaces the Facility resource.", - "parameters": [ { - "name": "id", + "name": "facilityId", "in": "path", - "description": "Facility identifier", + "description": "LegacyEquipment identifier", "required": true, "deprecated": false, "schema": { @@ -13666,251 +14102,194 @@ }, "style": "simple", "explode": false - } - ], - "requestBody": { - "description": "The updated Facility resource", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Facility.CreateFacilityInput" - } - } }, - "required": true - } - }, - "delete": { - "operationId": "canonical_facility_delete", - "tags": [ - "Facility" - ], - "responses": { - "204": { - "description": "Facility resource deleted" + { + "name": "page", + "in": "query", + "description": "The collection page number", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 1 + }, + "style": "form", + "explode": true }, - "403": { - "description": "Forbidden", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } + { + "name": "itemsPerPage", + "in": "query", + "description": "The number of items per page", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 30, + "minimum": 0, + "maximum": 100 }, - "links": {} + "style": "form", + "explode": true }, - "404": { - "description": "Not found", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } + { + "name": "family", + "in": "query", + "description": "LegacyEquipment family", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "enum": [ + "fire", + "safety", + "other" + ] }, - "links": {} - } - }, - "summary": "Removes the Facility resource.", - "description": "Removes the Facility resource.", - "parameters": [ + "style": "form", + "explode": true + }, { - "name": "id", - "in": "path", - "description": "Facility identifier", - "required": true, + "name": "customerId", + "in": "query", + "description": "LegacyEquipment customerId", + "required": false, "deprecated": false, "schema": { - "type": "string" + "type": "string", + "format": "uuid" }, - "style": "simple", - "explode": false - } - ] - }, - "patch": { - "operationId": "canonical_facility_patch", - "tags": [ - "Facility" - ], - "responses": { - "200": { - "description": "Facility resource updated", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Facility.FacilityOutput.jsonld" - } - } + "style": "form", + "explode": true + }, + { + "name": "includeDescendants", + "in": "query", + "description": "Include equipment assigned to published descendants of this facility.", + "required": false, + "deprecated": false, + "schema": { + "type": "boolean", + "default": false }, - "links": {} + "style": "form", + "explode": true }, - "400": { - "description": "Invalid input", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } + { + "name": "type", + "in": "query", + "description": "Filter by equipment type.", + "required": false, + "deprecated": false, + "schema": { + "type": "string" }, - "links": {} + "style": "form", + "explode": true }, - "403": { - "description": "Forbidden", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } + { + "name": "status", + "in": "query", + "description": "Filter by equipment status.", + "required": false, + "deprecated": false, + "schema": { + "type": "string" }, - "links": {} + "style": "form", + "explode": true }, - "404": { - "description": "Not found", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } + { + "name": "brand", + "in": "query", + "description": "Filter by exact equipment brand.", + "required": false, + "deprecated": false, + "schema": { + "type": "string" }, - "links": {} + "style": "form", + "explode": true }, - "422": { - "description": "An error occurred", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" - } - } + { + "name": "model", + "in": "query", + "description": "Filter by exact equipment model.", + "required": false, + "deprecated": false, + "schema": { + "type": "string" }, - "links": {} - } - }, - "summary": "Updates the Facility resource.", - "description": "Updates the Facility resource.", - "parameters": [ + "style": "form", + "explode": true + }, { - "name": "id", - "in": "path", - "description": "Facility identifier", - "required": true, + "name": "subType", + "in": "query", + "description": "Filter by exact equipment subtype.", + "required": false, "deprecated": false, "schema": { "type": "string" }, - "style": "simple", - "explode": false - } - ], - "requestBody": { - "description": "The updated Facility resource", - "content": { - "application/merge-patch+json": { - "schema": { - "$ref": "#/components/schemas/Facility.PatchCanonicalFacilityInput.jsonMergePatch" - } - } + "style": "form", + "explode": true }, - "required": true - } + { + "name": "maintenanceDueStatus", + "in": "query", + "description": "Filter by maintenance due status (`unscheduled`, `up_to_date`, `due_soon`, `overdue`); `due` combines due-soon and overdue before pagination.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "enum": [ + "unscheduled", + "up_to_date", + "due_soon", + "overdue", + "due" + ] + }, + "style": "form", + "explode": true + } + ] } }, - "/api/facility-attachments/{id}": { + "/api/organizations/{organizationId}/facilities/{facilityId}/equipment-summary": { "get": { - "operationId": "api_facility-attachments_id_get", + "operationId": "facility_equipment_summary_get", "tags": [ - "Facility" + "Equipment" ], "responses": { "200": { - "description": "Attachment retrieved", + "description": "Facility equipment totals retrieved", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/FacilityAttachment.FacilityAttachmentOutput.jsonld-facility.read" + "$ref": "#/components/schemas/FacilityEquipmentSummary.FacilityEquipmentSummaryOutput.jsonld" } } } }, + "400": { + "description": "Invalid scope or identifiers" + }, "403": { - "description": "Insufficient permissions" + "description": "Missing organization.equipment.read permission" }, "404": { - "description": "Attachment not found" + "description": "Facility not found in the organization" } }, - "summary": "Get a facility attachment", - "description": "Get a facility attachment", + "summary": "Get exact facility equipment totals", + "description": "Counts published equipment in the same direct or subtree scope as the equipment collection, before pagination.", "parameters": [ { - "name": "id", + "name": "organizationId", "in": "path", - "description": "FacilityAttachment identifier", + "description": "FacilityEquipmentSummary identifier", "required": true, "deprecated": false, "schema": { @@ -13918,38 +14297,11 @@ }, "style": "simple", "explode": false - } - ] - }, - "delete": { - "operationId": "api_facility-attachments_id_delete", - "tags": [ - "Facility" - ], - "responses": { - "204": { - "description": "Attachment deleted" - }, - "403": { - "description": "Insufficient permissions" - }, - "404": { - "description": "Attachment not found" }, - "412": { - "description": "Attachment revision is stale" - }, - "428": { - "description": "If-Match header is required" - } - }, - "summary": "Delete a facility attachment", - "description": "Delete a facility attachment", - "parameters": [ { - "name": "id", + "name": "facilityId", "in": "path", - "description": "FacilityAttachment identifier", + "description": "FacilityEquipmentSummary identifier", "required": true, "deprecated": false, "schema": { @@ -13957,30 +14309,72 @@ }, "style": "simple", "explode": false + }, + { + "name": "includeDescendants", + "in": "query", + "description": "FacilityEquipmentSummary includeDescendants", + "required": false, + "deprecated": false, + "schema": { + "type": "boolean", + "default": true + }, + "style": "form", + "explode": true + }, + { + "name": "family", + "in": "query", + "description": "FacilityEquipmentSummary family", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "enum": [ + "fire", + "safety", + "other" + ] + }, + "style": "form", + "explode": true + }, + { + "name": "customerId", + "in": "query", + "description": "FacilityEquipmentSummary customerId", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "uuid" + }, + "style": "form", + "explode": true } ] } }, - "/api/facility-attachments/{id}/calibration": { - "put": { - "operationId": "facility_attachment_set_calibration", + "/api/organizations/{organizationId}/equipment/{equipmentId}/inspection-summary": { + "get": { + "operationId": "equipment_inspection_summary_get", "tags": [ - "Facility" + "EquipmentInspectionSummary" ], "responses": { "200": { - "description": "Calibration saved", + "description": "EquipmentInspectionSummary resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/FacilityPlanCalibration.FacilityAttachmentOutput.jsonld-facility.read" + "$ref": "#/components/schemas/EquipmentInspectionSummary.EquipmentInspectionSummaryOutput.jsonld" } } - }, - "links": {} + } }, - "400": { - "description": "Invalid calibration", + "403": { + "description": "Forbidden", "content": { "application/ld+json": { "schema": { @@ -14000,63 +14394,35 @@ }, "links": {} }, - "403": { - "description": "Missing permission" - }, "404": { - "description": "Attachment not found" - }, - "409": { - "description": "Attachment is not a floor plan" - }, - "412": { - "description": "Stale revision" - }, - "422": { - "description": "An error occurred", + "description": "Not found", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation.jsonld" + "$ref": "#/components/schemas/Error.jsonld" } }, "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation" + "$ref": "#/components/schemas/Error" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation" + "$ref": "#/components/schemas/Error" } } }, "links": {} - }, - "428": { - "description": "If-Match is required" } }, - "summary": "Calibrate a floor plan in its building", - "description": "Sets a uniform metric scale, rotation and offset on the original floor plan image. Send calibration null to clear it. Requires organization.facilities.write and a matching attachment revision.", + "summary": "Retrieves a EquipmentInspectionSummary resource.", + "description": "Retrieves a EquipmentInspectionSummary resource.", "parameters": [ { - "name": "If-Match", - "in": "header", - "description": "", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false, - "example": "\"revision-1\"" - }, - { - "name": "id", + "name": "organizationId", "in": "path", - "description": "FacilityPlanCalibration identifier", + "description": "EquipmentInspectionSummary identifier", "required": true, "deprecated": false, "schema": { @@ -14064,48 +14430,11 @@ }, "style": "simple", "explode": false - } - ], - "requestBody": { - "description": "The updated FacilityPlanCalibration resource", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/FacilityPlanCalibration.SetFacilityAttachmentCalibrationInput-facility.write" - } - } - }, - "required": true - } - } - }, - "/api/facility-attachments/{id}/download": { - "get": { - "operationId": "download_facility_attachment", - "tags": [ - "Facility" - ], - "responses": { - "200": { - "description": "Attachment file content" - }, - "204": { - "description": "FacilityAttachmentContent resource" - }, - "403": { - "description": "Insufficient permissions (requires organization.facilities.read)" }, - "404": { - "description": "Attachment (or its stored content) not found" - } - }, - "summary": "Download a facility attachment", - "description": "Streams the stored file bytes with Content-Disposition: attachment and X-Content-Type-Options: nosniff. A floor_plan attachment may be an SVG — the shared AttachmentDownloadResponder never serves it inline, so a browser downloads rather than executes it. Gated by organization.facilities.read.", - "parameters": [ { - "name": "id", + "name": "equipmentId", "in": "path", - "description": "FacilityAttachmentContent identifier", + "description": "EquipmentInspectionSummary identifier", "required": true, "deprecated": false, "schema": { @@ -14117,26 +14446,45 @@ ] } }, - "/api/facility-attachments/{id}/primary": { - "post": { - "operationId": "facility_attachment_set_primary", + "/api/organizations/{organizationId}/equipment/{equipmentId}/open-work": { + "get": { + "operationId": "intervention_equipment_open_work_list", "tags": [ - "Facility" + "EquipmentOpenWork" ], "responses": { "200": { - "description": "Attachment promoted to primary plan", + "description": "EquipmentOpenWork collection", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/FacilityAttachment.FacilityAttachmentOutput.jsonld-facility.read" + "type": "object", + "description": "EquipmentOpenWork.EquipmentOpenWorkOutput.jsonld collection.", + "allOf": [ + { + "$ref": "#/components/schemas/HydraCollectionBaseSchemaNoPagination" + }, + { + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EquipmentOpenWork.EquipmentOpenWorkOutput.jsonld" + } + } + } + } + ] } } - }, - "links": {} + } }, - "400": { - "description": "Invalid input", + "403": { + "description": "Forbidden", "content": { "application/ld+json": { "schema": { @@ -14155,45 +14503,27 @@ } }, "links": {} - }, - "403": { - "description": "Insufficient permissions" - }, - "404": { - "description": "Attachment not found" - }, - "409": { - "description": "The attachment is not a floor plan" - }, - "422": { - "description": "An error occurred", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" - } - } - }, - "links": {} } }, - "summary": "Set the facility primary floor plan", - "description": "Promotes a `floor_plan` attachment to be the facility's primary plan, atomically clearing whichever attachment carried the flag before. A `document` attachment is refused with 409 — a POST verb-action route, mirroring `/facilities/{id}/archive` and `/facilities/{id}/move`.", + "summary": "Retrieves the collection of EquipmentOpenWork resources.", + "description": "Retrieves the collection of EquipmentOpenWork resources.", "parameters": [ { - "name": "id", + "name": "organizationId", "in": "path", - "description": "FacilityAttachment identifier", + "description": "EquipmentOpenWork identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "equipmentId", + "in": "path", + "description": "EquipmentOpenWork identifier", "required": true, "deprecated": false, "schema": { @@ -14205,111 +14535,163 @@ ] } }, - "/api/facility-models/{id}": { + "/api/facilities": { "get": { - "operationId": "get_facility_model", + "operationId": "api_facilities_get_collection", "tags": [ "Facility" ], "responses": { "200": { - "description": "Model metadata with revision ETag", + "description": "Facility collection", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/FacilityModel.FacilityModelOutput.jsonld-facility.read" + "type": "object", + "description": "Facility.FacilityOutput.jsonld collection.", + "allOf": [ + { + "$ref": "#/components/schemas/HydraCollectionBaseSchema" + }, + { + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Facility.FacilityOutput.jsonld" + } + } + } + } + ] } } } }, "403": { - "description": "Missing facilities read permission" - }, - "404": { - "description": "Model outside organization scope or missing" + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} } }, - "summary": "Read model metadata, transformation and node associations", - "description": "Read model metadata, transformation and node associations", + "summary": "Retrieves the collection of Facility resources.", + "description": "Retrieves the collection of Facility resources.", "parameters": [ { - "name": "id", - "in": "path", - "description": "FacilityModel identifier", - "required": true, + "name": "page", + "in": "query", + "description": "The collection page number", + "required": false, "deprecated": false, "schema": { - "type": "string" + "type": "integer", + "default": 1 }, - "style": "simple", - "explode": false - } - ] - }, - "delete": { - "operationId": "delete_facility_model", - "tags": [ - "Facility" - ], - "responses": { - "204": { - "description": "Model deleted" - }, - "403": { - "description": "Missing facilities write permission" + "style": "form", + "explode": true }, - "404": { - "description": "Model outside organization scope or missing" + { + "name": "itemsPerPage", + "in": "query", + "description": "The number of items per page", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 50, + "minimum": 0, + "maximum": 100 + }, + "style": "form", + "explode": true }, - "412": { - "description": "Stale revision" + { + "name": "includePath", + "in": "query", + "description": "Resolve ancestor breadcrumbs for this page in one organization-scoped read.", + "required": false, + "deprecated": false, + "schema": { + "type": "boolean", + "default": false + }, + "style": "form", + "explode": true }, - "428": { - "description": "If-Match required" - } - }, - "summary": "Delete a model and its immutable file", - "description": "Requires If-Match: \"revision-N\".", - "parameters": [ { - "name": "If-Match", - "in": "header", - "description": "", - "required": true, + "name": "organization", + "in": "query", + "description": "Organization IRI. Required when intervention is omitted.", + "required": false, "deprecated": false, "schema": { - "type": "string", - "pattern": "^\"revision-[0-9]+\"$" + "type": "string" }, - "style": "simple", - "explode": false + "style": "form", + "explode": true }, { - "name": "id", - "in": "path", - "description": "FacilityModel identifier", - "required": true, + "name": "intervention", + "in": "query", + "description": "Intervention IRI. Also scopes the organization and defaults recordStatus to draft.", + "required": false, "deprecated": false, "schema": { "type": "string" }, - "style": "simple", - "explode": false + "style": "form", + "explode": true + }, + { + "name": "recordStatus", + "in": "query", + "description": "Lifecycle status of the representation.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "enum": [ + "draft", + "published" + ] + }, + "style": "form", + "explode": true } ] }, - "patch": { - "operationId": "update_facility_model", + "post": { + "operationId": "api_facilities_post", "tags": [ "Facility" ], "responses": { - "200": { - "description": "Settings saved", + "201": { + "description": "Facility resource created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/FacilityModel.FacilityModelOutput.jsonld-facility.read" + "$ref": "#/components/schemas/Facility.FacilityOutput.jsonld" } } }, @@ -14337,16 +14719,28 @@ "links": {} }, "403": { - "description": "Missing permission" - }, - "404": { - "description": "Model outside scope or missing" - }, - "412": { - "description": "Stale revision" + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} }, "422": { - "description": "Invalid transformation or association", + "description": "An error occurred", "content": { "application/ld+json": { "schema": { @@ -14365,139 +14759,126 @@ } }, "links": {} + } + }, + "summary": "Creates a Facility resource.", + "description": "Creates a Facility resource.", + "parameters": [], + "requestBody": { + "description": "The new Facility resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Facility.CreateFacilityInput" + } + } }, - "428": { - "description": "If-Match required" + "required": true + } + } + }, + "/api/facilities/{facilityId}/attachments": { + "get": { + "operationId": "api_facilities_facilityIdattachments_get_collection", + "tags": [ + "Facility" + ], + "responses": { + "200": { + "description": "Attachments retrieved", + "content": { + "application/ld+json": { + "schema": { + "type": "object", + "description": "FacilityAttachment.FacilityAttachmentOutput.jsonld-facility.read collection.", + "allOf": [ + { + "$ref": "#/components/schemas/HydraCollectionBaseSchema" + }, + { + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FacilityAttachment.FacilityAttachmentOutput.jsonld-facility.read" + } + } + } + } + ] + } + } + } + }, + "403": { + "description": "Insufficient permissions" + }, + "404": { + "description": "Facility not found" } }, - "summary": "Update a model transformation and associations", - "description": "Requires If-Match: \"revision-N\" and a complete transform. Omitted or null bindings preserve stored associations; unavailable historical nodes remain until explicitly removed or reassigned. New associations must belong to the nearest building frame.", + "summary": "List facility attachments", + "description": "Lists all attachments for a given facility.", "parameters": [ { - "name": "If-Match", - "in": "header", - "description": "", + "name": "facilityId", + "in": "path", + "description": "FacilityAttachment identifier", "required": true, "deprecated": false, "schema": { - "type": "string", - "pattern": "^\"revision-[0-9]+\"$" + "type": "string" }, "style": "simple", "explode": false }, { - "name": "id", - "in": "path", - "description": "FacilityModel identifier", - "required": true, + "name": "page", + "in": "query", + "description": "The collection page number", + "required": false, "deprecated": false, "schema": { - "type": "string" + "type": "integer", + "default": 1 }, - "style": "simple", - "explode": false - } - ], - "requestBody": { - "description": "The updated FacilityModel resource", - "content": { - "application/merge-patch+json": { - "schema": { - "type": "object", - "required": [ - "transform" - ], - "properties": { - "transform": { - "type": "object", - "required": [ - "scale", - "rotationDegrees", - "translation" - ], - "properties": { - "scale": { - "type": "number", - "exclusiveMinimum": 0 - }, - "rotationDegrees": { - "type": "number" - }, - "translation": { - "type": "object", - "required": [ - "x", - "y", - "z" - ], - "properties": { - "x": { - "type": "number" - }, - "y": { - "type": "number" - }, - "z": { - "type": "number" - } - } - } - } - }, - "bindings": { - "type": [ - "array", - "null" - ], - "items": { - "type": "object", - "required": [ - "nodeIndex", - "facilityId" - ], - "properties": { - "nodeIndex": { - "type": "integer", - "minimum": 0 - }, - "facilityId": { - "type": "string", - "format": "uuid" - } - } - } - }, - "removeBindingNodeIndices": { - "type": "array", - "uniqueItems": true, - "items": { - "type": "integer", - "minimum": 0 - }, - "description": "Explicitly remove stored node associations, including unavailable historical targets." - } - } - } - } + "style": "form", + "explode": true }, - "required": true - } - } - }, - "/api/facility-models/{id}/activate": { + { + "name": "kind", + "in": "query", + "description": "Filter by attachment kind (\"document\" or \"floor_plan\").", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "enum": [ + "document", + "floor_plan" + ] + }, + "style": "form", + "explode": true + } + ] + }, "post": { - "operationId": "activate_facility_model", + "operationId": "api_facilities_facilityIdattachments_post", "tags": [ "Facility" ], "responses": { - "200": { - "description": "Model activated", + "201": { + "description": "Attachment uploaded", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/FacilityModel.FacilityModelOutput.jsonld-facility.read" + "$ref": "#/components/schemas/FacilityAttachment.FacilityAttachmentOutput.jsonld-facility.read" } } }, @@ -14525,16 +14906,13 @@ "links": {} }, "403": { - "description": "Missing facilities write permission" + "description": "Insufficient permissions" }, "404": { - "description": "Model outside organization scope or missing" - }, - "412": { - "description": "Stale revision" + "description": "Facility not found" }, "422": { - "description": "Association outside building subtree", + "description": "MIME type or size rejected", "content": { "application/ld+json": { "schema": { @@ -14553,31 +14931,15 @@ } }, "links": {} - }, - "428": { - "description": "If-Match required" } }, - "summary": "Activate a model and atomically deactivate the previous model", - "description": "Requires If-Match: \"revision-N\".", + "summary": "Upload a facility attachment", + "description": "Uploads a multipart file attachment to a facility. An optional `kind` field (\"document\", the default, or \"floor_plan\") selects the attachment kind; a floor plan is restricted to image/png, image/jpeg, image/webp or image/svg+xml and has its pixel dimensions probed server-side.", "parameters": [ { - "name": "If-Match", - "in": "header", - "description": "", - "required": true, - "deprecated": false, - "schema": { - "type": "string", - "pattern": "^\"revision-[0-9]+\"$" - }, - "style": "simple", - "explode": false - }, - { - "name": "id", + "name": "facilityId", "in": "path", - "description": "FacilityModel identifier", + "description": "FacilityAttachment identifier", "required": true, "deprecated": false, "schema": { @@ -14589,103 +14951,73 @@ ] } }, - "/api/facility-models/{id}/download": { + "/api/facilities/{id}": { "get": { - "operationId": "download_facility_model", + "operationId": "api_facilities_id_get", "tags": [ "Facility" ], "responses": { "200": { - "description": "Immutable GLB file content", + "description": "Facility resource", "content": { - "model/gltf-binary": { + "application/ld+json": { "schema": { - "type": "string", - "format": "binary" + "$ref": "#/components/schemas/Facility.FacilityOutput.jsonld" } } } }, - "204": { - "description": "FacilityModel resource" - }, "403": { - "description": "Missing facilities read permission" - }, - "404": { - "description": "Model outside scope or missing" - } - }, - "summary": "Download the authenticated GLB file", - "description": "Download the authenticated GLB file", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "FacilityModel identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - } - ] - } - }, - "/api/organizations/{organizationId}/facilities": { - "get": { - "operationId": "facility_list", - "tags": [ - "Facility" - ], - "responses": { - "200": { - "description": "Facilities retrieved", + "description": "Forbidden", "content": { "application/ld+json": { "schema": { - "type": "object", - "description": "LegacyFacility.FacilityOutput.jsonld-facility.read collection.", - "allOf": [ - { - "$ref": "#/components/schemas/HydraCollectionBaseSchema" - }, - { - "type": "object", - "required": [ - "member" - ], - "properties": { - "member": { - "type": "array", - "items": { - "$ref": "#/components/schemas/LegacyFacility.FacilityOutput.jsonld-facility.read" - } - } - } - } - ] + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" } } - } - }, - "400": { - "description": "Invalid organization identifier" + }, + "links": {} }, - "403": { - "description": "Insufficient permissions" + "404": { + "description": "Not found", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} } }, - "summary": "List facilities", - "description": "Lists facilities for the target organization.", + "summary": "Retrieves a Facility resource.", + "description": "Retrieves a Facility resource.", "parameters": [ { - "name": "organizationId", + "name": "id", "in": "path", - "description": "LegacyFacility identifier", + "description": "Facility identifier", "required": true, "deprecated": false, "schema": { @@ -14693,205 +15025,220 @@ }, "style": "simple", "explode": false - }, - { - "name": "page", - "in": "query", - "description": "The collection page number", - "required": false, - "deprecated": false, - "schema": { - "type": "integer", - "default": 1 - }, - "style": "form", - "explode": true - }, - { - "name": "itemsPerPage", - "in": "query", - "description": "The number of items per page", - "required": false, - "deprecated": false, - "schema": { - "type": "integer", - "default": 30, - "minimum": 0, - "maximum": 200 - }, - "style": "form", - "explode": true - }, - { - "name": "parentForType", - "in": "query", - "description": "Eligible active parents for creating this facility type. Published by default; interventionId also admits authorized drafts of that intervention. Mutually exclusive with parentForFacilityId.", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "enum": [ - "site", - "building", - "floor", - "zone", - "area" - ] + } + ] + }, + "put": { + "operationId": "canonical_facility_put", + "tags": [ + "Facility" + ], + "responses": { + "201": { + "description": "Facility resource updated", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Facility.FacilityOutput.jsonld" + } + } }, - "style": "form", - "explode": true + "links": {} }, - { - "name": "parentForFacilityId", - "in": "query", - "description": "Eligible move destinations for this facility, excluding its subtree and invalid ancestry. Mutually exclusive with parentForType.", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "format": "uuid" + "400": { + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } }, - "style": "form", - "explode": true + "links": {} }, - { - "name": "interventionId", - "in": "query", - "description": "Parent preparation context, requiring parentForType or parentForFacilityId and intervention mutation access. Draft candidates belong only to this intervention; published candidates additionally require Facilities read.", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "format": "uuid" + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } }, - "style": "form", - "explode": true + "links": {} }, - { - "name": "includePath", - "in": "query", - "description": "Resolve ancestor breadcrumbs for this page in one organization-scoped read.", - "required": false, - "deprecated": false, - "schema": { - "type": "boolean", - "default": false + "404": { + "description": "Not found", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } }, - "style": "form", - "explode": true + "links": {} }, - { - "name": "includeArchived", - "in": "query", - "description": "When true, archived facilities are included. Default: false.", - "required": false, - "deprecated": false, - "schema": { - "type": "boolean", - "default": false + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } }, - "style": "form", - "explode": true - }, + "links": {} + } + }, + "summary": "Replaces the Facility resource.", + "description": "Replaces the Facility resource.", + "parameters": [ { - "name": "type", - "in": "query", - "description": "Filter by facility type.", - "required": false, + "name": "id", + "in": "path", + "description": "Facility identifier", + "required": true, "deprecated": false, "schema": { - "type": "string", - "enum": [ - "site", - "building", - "floor", - "zone", - "area" - ] + "type": "string" }, - "style": "form", - "explode": true + "style": "simple", + "explode": false + } + ], + "requestBody": { + "description": "The updated Facility resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Facility.CreateFacilityInput" + } + } }, - { - "name": "status", - "in": "query", - "description": "Filter by facility status.", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "enum": [ - "active", - "archived" - ] - }, - "style": "form", - "explode": true + "required": true + } + }, + "delete": { + "operationId": "canonical_facility_delete", + "tags": [ + "Facility" + ], + "responses": { + "204": { + "description": "Facility resource deleted" }, - { - "name": "parentFacilityId", - "in": "query", - "description": "Filter by direct parent facility identifier.", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "format": "uuid" + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } }, - "style": "form", - "explode": true + "links": {} }, - { - "name": "rootsOnly", - "in": "query", - "description": "When true, only facilities without a parent are returned. Cannot be combined with parentFacilityId.", - "required": false, - "deprecated": false, - "schema": { - "type": "boolean", - "default": false + "404": { + "description": "Not found", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } }, - "style": "form", - "explode": true - }, + "links": {} + } + }, + "summary": "Removes the Facility resource.", + "description": "Removes the Facility resource.", + "parameters": [ { - "name": "code", - "in": "query", - "description": "Filter by exact facility code.", - "required": false, + "name": "id", + "in": "path", + "description": "Facility identifier", + "required": true, "deprecated": false, "schema": { "type": "string" }, - "style": "form", - "explode": true - }, - { - "name": "hasCoordinates", - "in": "query", - "description": "When true, only facilities with both latitude and longitude set are returned. When false, only facilities missing coordinates are returned. Omit for no coordinate filtering.", - "required": false, - "deprecated": false, - "schema": { - "type": "boolean" - }, - "style": "form", - "explode": true + "style": "simple", + "explode": false } ] }, - "post": { - "operationId": "facility_create", + "patch": { + "operationId": "canonical_facility_patch", "tags": [ "Facility" ], "responses": { - "201": { - "description": "Facility created", + "200": { + "description": "Facility resource updated", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/LegacyFacility.FacilityOutput.jsonld-facility.read" + "$ref": "#/components/schemas/Facility.FacilityOutput.jsonld" } } }, @@ -14919,13 +15266,46 @@ "links": {} }, "403": { - "description": "Insufficient permissions" + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} }, "404": { - "description": "Parent facility not found" - }, - "409": { - "description": "Facility code already exists in this organization" + "description": "Not found", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} }, "422": { "description": "An error occurred", @@ -14949,13 +15329,13 @@ "links": {} } }, - "summary": "Create facility", - "description": "Creates a facility for the target organization.", + "summary": "Updates the Facility resource.", + "description": "Updates the Facility resource.", "parameters": [ { - "name": "organizationId", + "name": "id", "in": "path", - "description": "LegacyFacility identifier", + "description": "Facility identifier", "required": true, "deprecated": false, "schema": { @@ -14966,11 +15346,11 @@ } ], "requestBody": { - "description": "The new LegacyFacility resource", + "description": "The updated Facility resource", "content": { - "application/ld+json": { + "application/merge-patch+json": { "schema": { - "$ref": "#/components/schemas/LegacyFacility.CreateFacilityInput-facility.write" + "$ref": "#/components/schemas/Facility.PatchCanonicalFacilityInput.jsonMergePatch" } } }, @@ -14978,46 +15358,37 @@ } } }, - "/api/organizations/{organizationId}/facilities/address-suggestions": { + "/api/facility-attachments/{id}": { "get": { - "operationId": "facility_suggest_addresses", + "operationId": "api_facility-attachments_id_get", "tags": [ "Facility" ], "responses": { "200": { - "description": "Address matches in member with totalItems", + "description": "Attachment retrieved", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/LegacyFacility.SuggestAddressesOutput.jsonld-facility.read" + "$ref": "#/components/schemas/FacilityAttachment.FacilityAttachmentOutput.jsonld-facility.read" } } } }, - "400": { - "description": "Missing or invalid search text" - }, "403": { - "description": "Missing organization.facilities.write" + "description": "Insufficient permissions" }, "404": { - "description": "Organization outside caller scope" - }, - "429": { - "description": "User search budget exceeded" - }, - "503": { - "description": "Address provider unavailable" + "description": "Attachment not found" } }, - "summary": "Suggest postal addresses", - "description": "Returns up to five concrete international addresses. Requires organization.facilities.write. Thirty requests per minute per user; remote failures return 503, an empty member list means no match.", + "summary": "Get a facility attachment", + "description": "Get a facility attachment", "parameters": [ { - "name": "organizationId", + "name": "id", "in": "path", - "description": "LegacyFacility identifier", + "description": "FacilityAttachment identifier", "required": true, "deprecated": false, "schema": { @@ -15025,57 +15396,38 @@ }, "style": "simple", "explode": false - }, - { - "name": "q", - "in": "query", - "description": "LegacyFacility q", - "required": true, - "deprecated": false, - "schema": { - "type": "string", - "minLength": 3, - "maxLength": 250 - }, - "style": "form", - "explode": true } ] - } - }, - "/api/organizations/{organizationId}/facilities/export": { - "get": { - "operationId": "facility_export", + }, + "delete": { + "operationId": "api_facility-attachments_id_delete", "tags": [ "Facility" ], "responses": { - "200": { - "description": "CSV export streamed successfully" - }, "204": { - "description": "LegacyFacility resource" - }, - "400": { - "description": "Invalid organization identifier or an invalid enum filter value" + "description": "Attachment deleted" }, "403": { - "description": "Authenticated but missing organization.facilities.read" + "description": "Insufficient permissions" }, "404": { - "description": "The organization is outside the caller's scope" + "description": "Attachment not found" }, - "422": { - "description": "Export exceeds the row cap; narrow the filters and retry" + "412": { + "description": "Attachment revision is stale" + }, + "428": { + "description": "If-Match header is required" } }, - "summary": "Export facilities (CSV)", - "description": "Streams a CSV export of facilities (Content-Disposition: attachment) for the given organization, using the same filter subset as the list endpoint. Requires `organization.facilities.read` on the organization, resolved the same way the list endpoint resolves it — a resource-level ROLE_USER check alone does not grant access. Bounded to 50000 matching rows — the request is rejected with 422 if the filters match more; narrow with a more specific filter and retry. The first seven CSV columns (type, name, code, address, latitude, longitude, parentCode) are the round-trip contract read back by the bulk CSV import.", + "summary": "Delete a facility attachment", + "description": "Delete a facility attachment", "parameters": [ { - "name": "organizationId", + "name": "id", "in": "path", - "description": "LegacyFacility identifier", + "description": "FacilityAttachment identifier", "required": true, "deprecated": false, "schema": { @@ -15083,249 +15435,155 @@ }, "style": "simple", "explode": false - }, - { - "name": "includeArchived", - "in": "query", - "description": "When true, archived facilities are included. Default: false.", - "required": false, - "deprecated": false, - "schema": { - "type": "boolean", - "default": false - }, - "style": "form", - "explode": true - }, - { - "name": "type", - "in": "query", - "description": "Filter by facility type.", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "enum": [ - "site", - "building", - "floor", - "zone", - "area" - ] - }, - "style": "form", - "explode": true - }, - { - "name": "status", - "in": "query", - "description": "Filter by facility status.", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "enum": [ - "active", - "archived" - ] - }, - "style": "form", - "explode": true - }, - { - "name": "parentFacilityId", - "in": "query", - "description": "Filter by direct parent facility identifier.", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "format": "uuid" - }, - "style": "form", - "explode": true - }, - { - "name": "rootsOnly", - "in": "query", - "description": "When true, only facilities without a parent are returned. Cannot be combined with parentFacilityId.", - "required": false, - "deprecated": false, - "schema": { - "type": "boolean", - "default": false - }, - "style": "form", - "explode": true - }, - { - "name": "code", - "in": "query", - "description": "Filter by exact facility code.", - "required": false, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "form", - "explode": true - }, - { - "name": "search", - "in": "query", - "description": "Text search across facility name/code.", - "required": false, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "form", - "explode": true - }, - { - "name": "hasCoordinates", - "in": "query", - "description": "When true, only facilities with both latitude and longitude set are returned. When false, only facilities missing coordinates are returned. Omit for no coordinate filtering.", - "required": false, - "deprecated": false, - "schema": { - "type": "boolean" - }, - "style": "form", - "explode": true - } - ], - "security": [ - { - "bearerAuth": [] } ] } }, - "/api/organizations/{organizationId}/facilities/geocode": { - "get": { - "operationId": "facility_geocode", + "/api/facility-attachments/{id}/calibration": { + "put": { + "operationId": "facility_attachment_set_calibration", "tags": [ "Facility" ], "responses": { "200": { - "description": "Best-match coordinates for the address", + "description": "Calibration saved", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/LegacyFacility.GeocodeAddressOutput.jsonld-facility.read" + "$ref": "#/components/schemas/FacilityPlanCalibration.FacilityAttachmentOutput.jsonld-facility.read" } } - } + }, + "links": {} }, "400": { - "description": "Missing, empty, or too-long address" + "description": "Invalid calibration", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} }, "403": { - "description": "Authenticated but missing organization.facilities.write" + "description": "Missing permission" }, "404": { - "description": "No coordinates found for the address (or the organization is outside the caller's scope)" + "description": "Attachment not found" }, - "429": { - "description": "More than 30 geocoding requests in a minute" + "409": { + "description": "Attachment is not a floor plan" + }, + "412": { + "description": "Stale revision" + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + }, + "428": { + "description": "If-Match is required" } }, - "summary": "Geocode an address", - "description": "Resolves a free-form postal address to WGS 84 coordinates as an aid to facility data entry. The lookup is proxied server-side through the configured geocoding provider (Nominatim by default) — the browser never calls the provider directly. Requires `organization.facilities.write` on the organization (resolved via resolveAccess): geocoding exists to FILL a facility's coordinates, so it is gated as a write-path aid, not a read. Rate limited to 30 requests per minute per user; the shared outbound channel is additionally throttled server-side to 1 request per second (Nominatim usage policy).", + "summary": "Calibrate a floor plan in its building", + "description": "Sets a uniform metric scale, rotation and offset on the original floor plan image. Send calibration null to clear it. Requires organization.facilities.write and a matching attachment revision.", "parameters": [ { - "name": "organizationId", - "in": "path", - "description": "LegacyFacility identifier", + "name": "If-Match", + "in": "header", + "description": "", "required": true, "deprecated": false, "schema": { "type": "string" }, "style": "simple", - "explode": false + "explode": false, + "example": "\"revision-1\"" }, { - "name": "address", - "in": "query", - "description": "Free-form postal address to resolve (1 to 300 characters).", + "name": "id", + "in": "path", + "description": "FacilityPlanCalibration identifier", "required": true, "deprecated": false, "schema": { - "type": "string", - "maxLength": 300 + "type": "string" }, - "style": "form", - "explode": true + "style": "simple", + "explode": false } - ] + ], + "requestBody": { + "description": "The updated FacilityPlanCalibration resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/FacilityPlanCalibration.SetFacilityAttachmentCalibrationInput-facility.write" + } + } + }, + "required": true + } } }, - "/api/organizations/{organizationId}/facilities/{buildingId}/models": { + "/api/facility-attachments/{id}/download": { "get": { - "operationId": "list_facility_models", + "operationId": "download_facility_attachment", "tags": [ "Facility" ], "responses": { "200": { - "description": "Building model collection", - "content": { - "application/ld+json": { - "schema": { - "type": "object", - "description": "FacilityModel.FacilityModelOutput.jsonld-facility.read collection.", - "allOf": [ - { - "$ref": "#/components/schemas/HydraCollectionBaseSchemaNoPagination" - }, - { - "type": "object", - "required": [ - "member" - ], - "properties": { - "member": { - "type": "array", - "items": { - "$ref": "#/components/schemas/FacilityModel.FacilityModelOutput.jsonld-facility.read" - } - } - } - } - ] - } - } - } + "description": "Attachment file content" + }, + "204": { + "description": "FacilityAttachmentContent resource" }, "403": { - "description": "Missing facilities read permission" + "description": "Insufficient permissions (requires organization.facilities.read)" }, "404": { - "description": "Building outside organization scope or missing" + "description": "Attachment (or its stored content) not found" } }, - "summary": "List a building active and draft models", - "description": "List a building active and draft models", + "summary": "Download a facility attachment", + "description": "Streams the stored file bytes with Content-Disposition: attachment and X-Content-Type-Options: nosniff. A floor_plan attachment may be an SVG — the shared AttachmentDownloadResponder never serves it inline, so a browser downloads rather than executes it. Gated by organization.facilities.read.", "parameters": [ { - "name": "organizationId", - "in": "path", - "description": "FacilityModel identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - }, - { - "name": "buildingId", + "name": "id", "in": "path", - "description": "FacilityModel identifier", + "description": "FacilityAttachmentContent identifier", "required": true, "deprecated": false, "schema": { @@ -15335,19 +15593,21 @@ "explode": false } ] - }, + } + }, + "/api/facility-attachments/{id}/primary": { "post": { - "operationId": "upload_facility_model", + "operationId": "facility_attachment_set_primary", "tags": [ "Facility" ], "responses": { - "201": { - "description": "Draft model uploaded", + "200": { + "description": "Attachment promoted to primary plan", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/FacilityModel.FacilityModelOutput.jsonld-facility.read" + "$ref": "#/components/schemas/FacilityAttachment.FacilityAttachmentOutput.jsonld-facility.read" } } }, @@ -15375,16 +15635,16 @@ "links": {} }, "403": { - "description": "Missing facilities write permission" + "description": "Insufficient permissions" }, "404": { - "description": "Building outside organization scope or missing" + "description": "Attachment not found" }, "409": { - "description": "Two models already exist" + "description": "The attachment is not a floor plan" }, "422": { - "description": "Invalid GLB, unsupported required extension or file over 10 MiB", + "description": "An error occurred", "content": { "application/ld+json": { "schema": { @@ -15405,25 +15665,13 @@ "links": {} } }, - "summary": "Upload a self-contained GLB 2.0 model", - "description": "Upload a self-contained GLB 2.0 model", + "summary": "Set the facility primary floor plan", + "description": "Promotes a `floor_plan` attachment to be the facility's primary plan, atomically clearing whichever attachment carried the flag before. A `document` attachment is refused with 409 — a POST verb-action route, mirroring `/facilities/{id}/archive` and `/facilities/{id}/move`.", "parameters": [ { - "name": "organizationId", - "in": "path", - "description": "FacilityModel identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - }, - { - "name": "buildingId", + "name": "id", "in": "path", - "description": "FacilityModel identifier", + "description": "FacilityAttachment identifier", "required": true, "deprecated": false, "schema": { @@ -15432,62 +15680,40 @@ "style": "simple", "explode": false } - ], - "requestBody": { - "content": { - "multipart/form-data": { - "schema": { - "type": "object", - "required": [ - "file" - ], - "properties": { - "file": { - "type": "string", - "format": "binary" - } - } - } - } - }, - "required": true - } + ] } }, - "/api/organizations/{organizationId}/facilities/{facilityId}": { + "/api/facility-models/{id}": { "get": { - "operationId": "facility_get", + "operationId": "get_facility_model", "tags": [ "Facility" ], "responses": { "200": { - "description": "Facility retrieved", + "description": "Model metadata with revision ETag", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/LegacyFacility.FacilityOutput.jsonld-facility.read" + "$ref": "#/components/schemas/FacilityModel.FacilityModelOutput.jsonld-facility.read" } } } }, - "400": { - "description": "Invalid identifier" - }, "403": { - "description": "Insufficient permissions" + "description": "Missing facilities read permission" }, "404": { - "description": "Facility not found" + "description": "Model outside organization scope or missing" } }, - "summary": "Get facility", - "description": "Returns one facility by identifier for the target organization.", + "summary": "Read model metadata, transformation and node associations", + "description": "Read model metadata, transformation and node associations", "parameters": [ { - "name": "organizationId", + "name": "id", "in": "path", - "description": "LegacyFacility identifier", + "description": "FacilityModel identifier", "required": true, "deprecated": false, "schema": { @@ -15495,11 +15721,51 @@ }, "style": "simple", "explode": false + } + ] + }, + "delete": { + "operationId": "delete_facility_model", + "tags": [ + "Facility" + ], + "responses": { + "204": { + "description": "Model deleted" + }, + "403": { + "description": "Missing facilities write permission" + }, + "404": { + "description": "Model outside organization scope or missing" }, + "412": { + "description": "Stale revision" + }, + "428": { + "description": "If-Match required" + } + }, + "summary": "Delete a model and its immutable file", + "description": "Requires If-Match: \"revision-N\".", + "parameters": [ { - "name": "facilityId", + "name": "If-Match", + "in": "header", + "description": "", + "required": true, + "deprecated": false, + "schema": { + "type": "string", + "pattern": "^\"revision-[0-9]+\"$" + }, + "style": "simple", + "explode": false + }, + { + "name": "id", "in": "path", - "description": "LegacyFacility identifier", + "description": "FacilityModel identifier", "required": true, "deprecated": false, "schema": { @@ -15511,17 +15777,17 @@ ] }, "patch": { - "operationId": "facility_update", + "operationId": "update_facility_model", "tags": [ "Facility" ], "responses": { "200": { - "description": "Facility updated", + "description": "Settings saved", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/LegacyFacility.FacilityOutput.jsonld-facility.read" + "$ref": "#/components/schemas/FacilityModel.FacilityModelOutput.jsonld-facility.read" } } }, @@ -15549,16 +15815,16 @@ "links": {} }, "403": { - "description": "Insufficient permissions" + "description": "Missing permission" }, "404": { - "description": "Facility not found" + "description": "Model outside scope or missing" }, - "409": { - "description": "Facility code already exists in this organization" + "412": { + "description": "Stale revision" }, "422": { - "description": "An error occurred", + "description": "Invalid transformation or association", "content": { "application/ld+json": { "schema": { @@ -15577,27 +15843,31 @@ } }, "links": {} + }, + "428": { + "description": "If-Match required" } }, - "summary": "Patch facility", - "description": "Partially updates facility information.", + "summary": "Update a model transformation and associations", + "description": "Requires If-Match: \"revision-N\" and a complete transform. Omitted or null bindings preserve stored associations; unavailable historical nodes remain until explicitly removed or reassigned. New associations must belong to the nearest building frame.", "parameters": [ { - "name": "organizationId", - "in": "path", - "description": "LegacyFacility identifier", + "name": "If-Match", + "in": "header", + "description": "", "required": true, "deprecated": false, "schema": { - "type": "string" + "type": "string", + "pattern": "^\"revision-[0-9]+\"$" }, "style": "simple", "explode": false }, { - "name": "facilityId", + "name": "id", "in": "path", - "description": "LegacyFacility identifier", + "description": "FacilityModel identifier", "required": true, "deprecated": false, "schema": { @@ -15608,38 +15878,111 @@ } ], "requestBody": { - "description": "The updated LegacyFacility resource", + "description": "The updated FacilityModel resource", "content": { "application/merge-patch+json": { "schema": { - "$ref": "#/components/schemas/LegacyFacility.UpdateFacilityInput-facility.write.jsonMergePatch" - } - } - }, - "required": true - } - } - }, - "/api/organizations/{organizationId}/facilities/{facilityId}/archive": { - "post": { - "operationId": "facility_archive", + "type": "object", + "required": [ + "transform" + ], + "properties": { + "transform": { + "type": "object", + "required": [ + "scale", + "rotationDegrees", + "translation" + ], + "properties": { + "scale": { + "type": "number", + "exclusiveMinimum": 0 + }, + "rotationDegrees": { + "type": "number" + }, + "translation": { + "type": "object", + "required": [ + "x", + "y", + "z" + ], + "properties": { + "x": { + "type": "number" + }, + "y": { + "type": "number" + }, + "z": { + "type": "number" + } + } + } + } + }, + "bindings": { + "type": [ + "array", + "null" + ], + "items": { + "type": "object", + "required": [ + "nodeIndex", + "facilityId" + ], + "properties": { + "nodeIndex": { + "type": "integer", + "minimum": 0 + }, + "facilityId": { + "type": "string", + "format": "uuid" + } + } + } + }, + "removeBindingNodeIndices": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "integer", + "minimum": 0 + }, + "description": "Explicitly remove stored node associations, including unavailable historical targets." + } + } + } + } + }, + "required": true + } + } + }, + "/api/facility-models/{id}/activate": { + "post": { + "operationId": "activate_facility_model", "tags": [ "Facility" ], "responses": { "200": { - "description": "Facility archived", + "description": "Model activated", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/LegacyFacility.FacilityOutput.jsonld-facility.read" + "$ref": "#/components/schemas/FacilityModel.FacilityModelOutput.jsonld-facility.read" } } }, "links": {} }, "400": { - "description": "Invalid identifier", + "description": "Invalid input", "content": { "application/ld+json": { "schema": { @@ -15660,13 +16003,16 @@ "links": {} }, "403": { - "description": "Insufficient permissions" + "description": "Missing facilities write permission" }, "404": { - "description": "Facility not found" + "description": "Model outside organization scope or missing" + }, + "412": { + "description": "Stale revision" }, "422": { - "description": "An error occurred", + "description": "Association outside building subtree", "content": { "application/ld+json": { "schema": { @@ -15685,27 +16031,31 @@ } }, "links": {} + }, + "428": { + "description": "If-Match required" } }, - "summary": "Archive facility", - "description": "Archives one facility.", + "summary": "Activate a model and atomically deactivate the previous model", + "description": "Requires If-Match: \"revision-N\".", "parameters": [ { - "name": "organizationId", - "in": "path", - "description": "LegacyFacility identifier", + "name": "If-Match", + "in": "header", + "description": "", "required": true, "deprecated": false, "schema": { - "type": "string" + "type": "string", + "pattern": "^\"revision-[0-9]+\"$" }, "style": "simple", "explode": false }, { - "name": "facilityId", + "name": "id", "in": "path", - "description": "LegacyFacility identifier", + "description": "FacilityModel identifier", "required": true, "deprecated": false, "schema": { @@ -15714,56 +16064,44 @@ "style": "simple", "explode": false } - ], - "deprecated": true + ] } }, - "/api/organizations/{organizationId}/facilities/{facilityId}/building-model": { + "/api/facility-models/{id}/download": { "get": { - "operationId": "facility_building_model_get", + "operationId": "download_facility_model", "tags": [ "Facility" ], "responses": { "200": { - "description": "Building model retrieved", + "description": "Immutable GLB file content", "content": { - "application/ld+json": { + "model/gltf-binary": { "schema": { - "$ref": "#/components/schemas/LegacyFacility.FacilityBuildingModelOutput.jsonld-facility.read" + "type": "string", + "format": "binary" } } } }, + "204": { + "description": "FacilityModel resource" + }, "403": { - "description": "Insufficient permissions" + "description": "Missing facilities read permission" }, "404": { - "description": "Facility not found" - }, - "409": { - "description": "Facility is not a building" + "description": "Model outside scope or missing" } }, - "summary": "Get facility 3D building model", - "description": "Assembles, for a `building` facility, the ordered stack of floors a 3D viewer extrudes — each floor's outline and its rooms. A building with no floors, a floor with no primary plan, or a floor with no room are all valid \"200\" shapes, never errors.", + "summary": "Download the authenticated GLB file", + "description": "Download the authenticated GLB file", "parameters": [ { - "name": "organizationId", - "in": "path", - "description": "LegacyFacility identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - }, - { - "name": "facilityId", + "name": "id", "in": "path", - "description": "LegacyFacility identifier", + "description": "FacilityModel identifier", "required": true, "deprecated": false, "schema": { @@ -15775,15 +16113,15 @@ ] } }, - "/api/organizations/{organizationId}/facilities/{facilityId}/children": { + "/api/organizations/{organizationId}/facilities": { "get": { - "operationId": "facility_list_children", + "operationId": "facility_list", "tags": [ "Facility" ], "responses": { "200": { - "description": "Facility children retrieved", + "description": "Facilities retrieved", "content": { "application/ld+json": { "schema": { @@ -15813,17 +16151,14 @@ } }, "400": { - "description": "Invalid identifier" + "description": "Invalid organization identifier" }, "403": { "description": "Insufficient permissions" - }, - "404": { - "description": "Facility not found" } }, - "summary": "List facility children", - "description": "Lists direct children for one facility. This is the lazy tree expansion endpoint.", + "summary": "List facilities", + "description": "Lists facilities for the target organization.", "parameters": [ { "name": "organizationId", @@ -15837,18 +16172,6 @@ "style": "simple", "explode": false }, - { - "name": "facilityId", - "in": "path", - "description": "LegacyFacility identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - }, { "name": "page", "in": "query", @@ -15878,149 +16201,141 @@ "explode": true }, { - "name": "includePath", + "name": "customerId", "in": "query", - "description": "Resolve ancestor breadcrumbs for this page in one organization-scoped read.", + "description": "Filter root sites assigned to this internal customer.", "required": false, "deprecated": false, "schema": { - "type": "boolean", - "default": false + "type": "string", + "format": "uuid" }, "style": "form", "explode": true }, { - "name": "includeArchived", + "name": "parentForType", "in": "query", - "description": "When true, archived children are included.", + "description": "Eligible active parents for creating this facility type. Published by default; interventionId also admits authorized drafts of that intervention. Mutually exclusive with parentForFacilityId.", "required": false, "deprecated": false, "schema": { - "type": "boolean", - "default": false + "type": "string", + "enum": [ + "site", + "building", + "floor", + "zone", + "area" + ] }, "style": "form", "explode": true }, { - "name": "search", + "name": "parentForFacilityId", "in": "query", - "description": "Text search across child facilities.", + "description": "Eligible move destinations for this facility, excluding its subtree and invalid ancestry. Mutually exclusive with parentForType.", "required": false, "deprecated": false, "schema": { - "type": "string" + "type": "string", + "format": "uuid" }, "style": "form", "explode": true - } - ] - } - }, - "/api/organizations/{organizationId}/facilities/{facilityId}/descendants": { - "get": { - "operationId": "facility_list_descendants", - "tags": [ - "Facility" - ], - "responses": { - "200": { - "description": "Facility descendants retrieved", - "content": { - "application/ld+json": { - "schema": { - "type": "object", - "description": "LegacyFacility.FacilityOutput.jsonld-facility.read collection.", - "allOf": [ - { - "$ref": "#/components/schemas/HydraCollectionBaseSchemaNoPagination" - }, - { - "type": "object", - "required": [ - "member" - ], - "properties": { - "member": { - "type": "array", - "items": { - "$ref": "#/components/schemas/LegacyFacility.FacilityOutput.jsonld-facility.read" - } - } - } - } - ] - } - } - } }, - "400": { - "description": "Invalid identifier" + { + "name": "interventionId", + "in": "query", + "description": "Parent preparation context, requiring parentForType or parentForFacilityId and intervention mutation access. Draft candidates belong only to this intervention; published candidates additionally require Facilities read.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "uuid" + }, + "style": "form", + "explode": true }, - "403": { - "description": "Insufficient permissions" + { + "name": "includePath", + "in": "query", + "description": "Resolve ancestor breadcrumbs for this page in one organization-scoped read.", + "required": false, + "deprecated": false, + "schema": { + "type": "boolean", + "default": false + }, + "style": "form", + "explode": true }, - "404": { - "description": "Facility not found" - } - }, - "summary": "List facility descendants", - "description": "Lists descendants for one facility. Set pagination=true to read bounded pages with search and a total count.", - "parameters": [ { - "name": "organizationId", - "in": "path", - "description": "LegacyFacility identifier", - "required": true, + "name": "includeArchived", + "in": "query", + "description": "When true, archived facilities are included. Default: false.", + "required": false, "deprecated": false, "schema": { - "type": "string" + "type": "boolean", + "default": false }, - "style": "simple", - "explode": false + "style": "form", + "explode": true }, { - "name": "facilityId", - "in": "path", - "description": "LegacyFacility identifier", - "required": true, + "name": "type", + "in": "query", + "description": "Filter by facility type.", + "required": false, "deprecated": false, "schema": { - "type": "string" + "type": "string", + "enum": [ + "site", + "building", + "floor", + "zone", + "area" + ] }, - "style": "simple", - "explode": false + "style": "form", + "explode": true }, { - "name": "pagination", + "name": "status", "in": "query", - "description": "Enable or disable pagination", + "description": "Filter by facility status.", "required": false, "deprecated": false, "schema": { - "type": "boolean" + "type": "string", + "enum": [ + "active", + "archived" + ] }, "style": "form", "explode": true }, { - "name": "includePath", + "name": "parentFacilityId", "in": "query", - "description": "Resolve ancestor breadcrumbs for this page in one organization-scoped read.", + "description": "Filter by direct parent facility identifier.", "required": false, "deprecated": false, "schema": { - "type": "boolean", - "default": false + "type": "string", + "format": "uuid" }, "style": "form", "explode": true }, { - "name": "includeArchived", + "name": "rootsOnly", "in": "query", - "description": "When true, archived descendants are included.", + "description": "When true, only facilities without a parent are returned. Cannot be combined with parentFacilityId.", "required": false, "deprecated": false, "schema": { @@ -16031,9 +16346,9 @@ "explode": true }, { - "name": "search", + "name": "code", "in": "query", - "description": "Text search across descendant facilities.", + "description": "Filter by exact facility code.", "required": false, "deprecated": false, "schema": { @@ -16041,19 +16356,29 @@ }, "style": "form", "explode": true + }, + { + "name": "hasCoordinates", + "in": "query", + "description": "When true, only facilities with both latitude and longitude set are returned. When false, only facilities missing coordinates are returned. Omit for no coordinate filtering.", + "required": false, + "deprecated": false, + "schema": { + "type": "boolean" + }, + "style": "form", + "explode": true } ] - } - }, - "/api/organizations/{organizationId}/facilities/{facilityId}/duplicate": { + }, "post": { - "operationId": "facility_duplicate_subtree", + "operationId": "facility_create", "tags": [ "Facility" ], "responses": { "201": { - "description": "Facility subtree duplicated", + "description": "Facility created", "content": { "application/ld+json": { "schema": { @@ -16064,7 +16389,7 @@ "links": {} }, "400": { - "description": "Invalid input or target parent", + "description": "Invalid input", "content": { "application/ld+json": { "schema": { @@ -16088,13 +16413,13 @@ "description": "Insufficient permissions" }, "404": { - "description": "Facility not found" + "description": "Parent facility not found" }, "409": { - "description": "Source facility is archived, or the organization plan quota would be exceeded" + "description": "Facility code already exists in this organization" }, "422": { - "description": "Subtree exceeds the duplication size limit", + "description": "An error occurred", "content": { "application/ld+json": { "schema": { @@ -16115,8 +16440,8 @@ "links": {} } }, - "summary": "Duplicate facility subtree", - "description": "Duplicates a facility and its full subtree into a new, independent branch.", + "summary": "Create facility", + "description": "Creates a facility for the target organization.", "parameters": [ { "name": "organizationId", @@ -16129,18 +16454,6 @@ }, "style": "simple", "explode": false - }, - { - "name": "facilityId", - "in": "path", - "description": "LegacyFacility identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false } ], "requestBody": { @@ -16148,7 +16461,7 @@ "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/LegacyFacility.DuplicateFacilitySubtreeInput-facility.write" + "$ref": "#/components/schemas/LegacyFacility.CreateFacilityInput-facility.write" } } }, @@ -16156,95 +16469,42 @@ } } }, - "/api/organizations/{organizationId}/facilities/{facilityId}/move": { - "post": { - "operationId": "facility_move", + "/api/organizations/{organizationId}/facilities/address-suggestions": { + "get": { + "operationId": "facility_suggest_addresses", "tags": [ "Facility" ], "responses": { "200": { - "description": "Facility moved", + "description": "Address matches in member with totalItems", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/LegacyFacility.FacilityOutput.jsonld-facility.read" + "$ref": "#/components/schemas/LegacyFacility.SuggestAddressesOutput.jsonld-facility.read" } } - }, - "links": {} + } }, "400": { - "description": "Invalid request", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "links": {} + "description": "Missing or invalid search text" }, "403": { - "description": "Insufficient permissions" + "description": "Missing organization.facilities.write" }, "404": { - "description": "Facility not found" - }, - "412": { - "description": "Stale revision" + "description": "Organization outside caller scope" }, - "422": { - "description": "Invalid hierarchy", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" - } - } - }, - "links": {} + "429": { + "description": "User search budget exceeded" }, - "428": { - "description": "If-Match is required" + "503": { + "description": "Address provider unavailable" } }, - "summary": "Move facility", - "description": "Moves a facility under a valid parent using its current revision.", + "summary": "Suggest postal addresses", + "description": "Returns up to five concrete international addresses. Requires organization.facilities.write. Thirty requests per minute per user; remote failures return 503, an empty member list means no match.", "parameters": [ - { - "name": "If-Match", - "in": "header", - "description": "The current facility revision, for example \"revision-1\".", - "required": true, - "deprecated": false, - "schema": { - "type": "string", - "pattern": "^\"revision-[0-9]+\"$" - }, - "style": "simple", - "explode": false - }, { "name": "organizationId", "in": "path", @@ -16258,103 +16518,50 @@ "explode": false }, { - "name": "facilityId", - "in": "path", - "description": "LegacyFacility identifier", + "name": "q", + "in": "query", + "description": "LegacyFacility q", "required": true, "deprecated": false, "schema": { - "type": "string" + "type": "string", + "minLength": 3, + "maxLength": 250 }, - "style": "simple", - "explode": false + "style": "form", + "explode": true } - ], - "requestBody": { - "description": "The new LegacyFacility resource", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/LegacyFacility.MoveFacilityInput-facility.write" - } - } - }, - "required": true - } + ] } }, - "/api/organizations/{organizationId}/facilities/{facilityId}/plan-geometry": { - "put": { - "operationId": "facility_plan_geometry_set", + "/api/organizations/{organizationId}/facilities/export": { + "get": { + "operationId": "facility_export", "tags": [ "Facility" ], "responses": { "200": { - "description": "Plan geometry set or cleared", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/LegacyFacility.FacilityOutput.jsonld-facility.read" - } - } - }, - "links": {} + "description": "CSV export streamed successfully" + }, + "204": { + "description": "LegacyFacility resource" }, "400": { - "description": "Invalid geometry shape, bounds, or point count", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "links": {} + "description": "Invalid organization identifier or an invalid enum filter value" }, "403": { - "description": "Insufficient permissions" + "description": "Authenticated but missing organization.facilities.read" }, "404": { - "description": "Facility or attachment not found" - }, - "409": { - "description": "Attachment is not a floor plan, or does not belong to this facility or an ancestor" + "description": "The organization is outside the caller's scope" }, "422": { - "description": "An error occurred", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" - } - } - }, - "links": {} + "description": "Export exceeds the row cap; narrow the filters and retry" } }, - "summary": "Set facility plan geometry", - "description": "Binds this facility (a spatial zone) to a polygon drawn over an ancestor floor plan attachment. Submit \"attachmentId\" and \"points\" (>=3 normalized 0-1 coordinates) together to set or replace the geometry, or both null to clear it.", + "summary": "Export facilities (CSV)", + "description": "Streams a CSV export of facilities (Content-Disposition: attachment) for the given organization, using the same filter subset as the list endpoint. Requires `organization.facilities.read` on the organization, resolved the same way the list endpoint resolves it — a resource-level ROLE_USER check alone does not grant access. Bounded to 50000 matching rows — the request is rejected with 422 if the filters match more; narrow with a more specific filter and retry. The first seven CSV columns (type, name, code, address, latitude, longitude, parentCode) are the round-trip contract read back by the bulk CSV import.", "parameters": [ { "name": "organizationId", @@ -16369,173 +16576,155 @@ "explode": false }, { - "name": "facilityId", - "in": "path", - "description": "LegacyFacility identifier", - "required": true, + "name": "includeArchived", + "in": "query", + "description": "When true, archived facilities are included. Default: false.", + "required": false, "deprecated": false, "schema": { - "type": "string" + "type": "boolean", + "default": false }, - "style": "simple", - "explode": false - } - ], - "requestBody": { - "description": "The updated LegacyFacility resource", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/LegacyFacility.SetFacilityPlanGeometryInput-facility.write" - } - } + "style": "form", + "explode": true }, - "required": true - } - } - }, - "/api/organizations/{organizationId}/facilities/{facilityId}/plan-overlay": { - "get": { - "operationId": "facility_plan_overlay_get", - "tags": [ - "Facility" - ], - "responses": { - "200": { - "description": "Plan overlay retrieved", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/LegacyFacility.FacilityPlanOverlayOutput.jsonld-facility.read" - } - } - } + { + "name": "type", + "in": "query", + "description": "Filter by facility type.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "enum": [ + "site", + "building", + "floor", + "zone", + "area" + ] + }, + "style": "form", + "explode": true }, - "400": { - "description": "Invalid identifier" + { + "name": "status", + "in": "query", + "description": "Filter by facility status.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "enum": [ + "active", + "archived" + ] + }, + "style": "form", + "explode": true }, - "403": { - "description": "Insufficient permissions" + { + "name": "parentFacilityId", + "in": "query", + "description": "Filter by direct parent facility identifier.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "uuid" + }, + "style": "form", + "explode": true }, - "404": { - "description": "Facility or attachment not found" + { + "name": "rootsOnly", + "in": "query", + "description": "When true, only facilities without a parent are returned. Cannot be combined with parentFacilityId.", + "required": false, + "deprecated": false, + "schema": { + "type": "boolean", + "default": false + }, + "style": "form", + "explode": true }, - "409": { - "description": "Attachment is not a floor plan, or does not belong to this facility or an ancestor" - } - }, - "summary": "Get facility plan overlay", - "description": "Returns one floor plan (explicit \"attachmentId\", or this facility's own primary plan when omitted) and every published self-or-descendant zone bound to it.", - "parameters": [ { - "name": "organizationId", - "in": "path", - "description": "LegacyFacility identifier", - "required": true, + "name": "code", + "in": "query", + "description": "Filter by exact facility code.", + "required": false, "deprecated": false, "schema": { "type": "string" }, - "style": "simple", - "explode": false + "style": "form", + "explode": true }, { - "name": "facilityId", - "in": "path", - "description": "LegacyFacility identifier", - "required": true, + "name": "search", + "in": "query", + "description": "Text search across facility name/code.", + "required": false, "deprecated": false, "schema": { "type": "string" }, - "style": "simple", - "explode": false + "style": "form", + "explode": true }, { - "name": "attachmentId", + "name": "hasCoordinates", "in": "query", - "description": "Floor plan attachment identifier. Defaults to this facility's primary plan when omitted.", + "description": "When true, only facilities with both latitude and longitude set are returned. When false, only facilities missing coordinates are returned. Omit for no coordinate filtering.", "required": false, "deprecated": false, "schema": { - "type": "string", - "format": "uuid" + "type": "boolean" }, "style": "form", "explode": true } + ], + "security": [ + { + "bearerAuth": [] + } ] } }, - "/api/organizations/{organizationId}/facilities/{facilityId}/restore": { - "patch": { - "operationId": "facility_restore", + "/api/organizations/{organizationId}/facilities/geocode": { + "get": { + "operationId": "facility_geocode", "tags": [ "Facility" ], "responses": { "200": { - "description": "Facility restored", + "description": "Best-match coordinates for the address", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/LegacyFacility.FacilityOutput.jsonld-facility.read" + "$ref": "#/components/schemas/LegacyFacility.GeocodeAddressOutput.jsonld-facility.read" } } - }, - "links": {} + } }, "400": { - "description": "Invalid identifier or parent state", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "links": {} + "description": "Missing, empty, or too-long address" }, "403": { - "description": "Insufficient permissions" + "description": "Authenticated but missing organization.facilities.write" }, "404": { - "description": "Facility not found" + "description": "No coordinates found for the address (or the organization is outside the caller's scope)" }, - "422": { - "description": "An error occurred", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" - } - } - }, - "links": {} + "429": { + "description": "More than 30 geocoding requests in a minute" } }, - "summary": "Restore facility", - "description": "Restores an archived facility.", + "summary": "Geocode an address", + "description": "Resolves a free-form postal address to WGS 84 coordinates as an aid to facility data entry. The lookup is proxied server-side through the configured geocoding provider (Nominatim by default) — the browser never calls the provider directly. Requires `organization.facilities.write` on the organization (resolved via resolveAccess): geocoding exists to FILL a facility's coordinates, so it is gated as a write-path aid, not a read. Rate limited to 30 requests per minute per user; the shared outbound channel is additionally throttled server-side to 1 request per second (Nominatim usage policy).", "parameters": [ { "name": "organizationId", @@ -16550,34 +16739,35 @@ "explode": false }, { - "name": "facilityId", - "in": "path", - "description": "LegacyFacility identifier", + "name": "address", + "in": "query", + "description": "Free-form postal address to resolve (1 to 300 characters).", "required": true, "deprecated": false, "schema": { - "type": "string" + "type": "string", + "maxLength": 300 }, - "style": "simple", - "explode": false + "style": "form", + "explode": true } ] } }, - "/api/organizations/{organizationId}/facility-metadata-fields": { + "/api/organizations/{organizationId}/facilities/{buildingId}/models": { "get": { - "operationId": "facility_metadata_field_list", + "operationId": "list_facility_models", "tags": [ "Facility" ], "responses": { "200": { - "description": "Metadata field definitions retrieved", + "description": "Building model collection", "content": { "application/ld+json": { "schema": { "type": "object", - "description": "FacilityMetadataField.FacilityMetadataFieldOutput.jsonld-facility.read collection.", + "description": "FacilityModel.FacilityModelOutput.jsonld-facility.read collection.", "allOf": [ { "$ref": "#/components/schemas/HydraCollectionBaseSchemaNoPagination" @@ -16591,7 +16781,7 @@ "member": { "type": "array", "items": { - "$ref": "#/components/schemas/FacilityMetadataField.FacilityMetadataFieldOutput.jsonld-facility.read" + "$ref": "#/components/schemas/FacilityModel.FacilityModelOutput.jsonld-facility.read" } } } @@ -16601,23 +16791,32 @@ } } }, - "400": { - "description": "Invalid organization identifier" - }, "403": { - "description": "Insufficient permissions" + "description": "Missing facilities read permission" }, "404": { - "description": "Organization not found" + "description": "Building outside organization scope or missing" } }, - "summary": "List facility metadata field definitions", - "description": "Lists the organization's typed metadata field definitions. Doubles as the frontend's form-schema source.", + "summary": "List a building active and draft models", + "description": "List a building active and draft models", "parameters": [ { "name": "organizationId", "in": "path", - "description": "FacilityMetadataField identifier", + "description": "FacilityModel identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "buildingId", + "in": "path", + "description": "FacilityModel identifier", "required": true, "deprecated": false, "schema": { @@ -16629,17 +16828,17 @@ ] }, "post": { - "operationId": "facility_metadata_field_create", + "operationId": "upload_facility_model", "tags": [ "Facility" ], "responses": { "201": { - "description": "Metadata field created", + "description": "Draft model uploaded", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/FacilityMetadataField.FacilityMetadataFieldOutput.jsonld-facility.read" + "$ref": "#/components/schemas/FacilityModel.FacilityModelOutput.jsonld-facility.read" } } }, @@ -16667,16 +16866,16 @@ "links": {} }, "403": { - "description": "Insufficient permissions" + "description": "Missing facilities write permission" }, "404": { - "description": "Organization not found" + "description": "Building outside organization scope or missing" }, "409": { - "description": "Metadata field key already exists for this organization" + "description": "Two models already exist" }, "422": { - "description": "Organization has reached the metadata field definition cap (50)", + "description": "Invalid GLB, unsupported required extension or file over 10 MiB", "content": { "application/ld+json": { "schema": { @@ -16697,13 +16896,25 @@ "links": {} } }, - "summary": "Create a facility metadata field definition", - "description": "Creates one typed metadata field definition for the organization.", + "summary": "Upload a self-contained GLB 2.0 model", + "description": "Upload a self-contained GLB 2.0 model", "parameters": [ { "name": "organizationId", "in": "path", - "description": "FacilityMetadataField identifier", + "description": "FacilityModel identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "buildingId", + "in": "path", + "description": "FacilityModel identifier", "required": true, "deprecated": false, "schema": { @@ -16714,11 +16925,19 @@ } ], "requestBody": { - "description": "The new FacilityMetadataField resource", "content": { - "application/ld+json": { + "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/FacilityMetadataField.CreateFacilityMetadataFieldInput-facility.write" + "type": "object", + "required": [ + "file" + ], + "properties": { + "file": { + "type": "string", + "format": "binary" + } + } } } }, @@ -16726,30 +16945,40 @@ } } }, - "/api/organizations/{organizationId}/facility-metadata-fields/{id}": { - "delete": { - "operationId": "facility_metadata_field_delete", + "/api/organizations/{organizationId}/facilities/{facilityId}": { + "get": { + "operationId": "facility_get", "tags": [ "Facility" ], "responses": { - "204": { - "description": "Metadata field deleted" + "200": { + "description": "Facility retrieved", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/LegacyFacility.FacilityOutput.jsonld-facility.read" + } + } + } + }, + "400": { + "description": "Invalid identifier" }, "403": { "description": "Insufficient permissions" }, "404": { - "description": "Metadata field not found" + "description": "Facility not found" } }, - "summary": "Delete a facility metadata field definition", - "description": "Deletes one metadata field definition. Existing facility metadata values for this key are left untouched.", + "summary": "Get facility", + "description": "Returns one facility by identifier for the target organization.", "parameters": [ { "name": "organizationId", "in": "path", - "description": "FacilityMetadataField identifier", + "description": "LegacyFacility identifier", "required": true, "deprecated": false, "schema": { @@ -16759,9 +16988,9 @@ "explode": false }, { - "name": "id", + "name": "facilityId", "in": "path", - "description": "FacilityMetadataField identifier", + "description": "LegacyFacility identifier", "required": true, "deprecated": false, "schema": { @@ -16773,17 +17002,17 @@ ] }, "patch": { - "operationId": "facility_metadata_field_update", + "operationId": "facility_update", "tags": [ "Facility" ], "responses": { "200": { - "description": "Metadata field updated", + "description": "Facility updated", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/FacilityMetadataField.FacilityMetadataFieldOutput.jsonld-facility.read" + "$ref": "#/components/schemas/LegacyFacility.FacilityOutput.jsonld-facility.read" } } }, @@ -16814,7 +17043,10 @@ "description": "Insufficient permissions" }, "404": { - "description": "Metadata field not found" + "description": "Facility not found" + }, + "409": { + "description": "Facility code already exists in this organization" }, "422": { "description": "An error occurred", @@ -16838,13 +17070,13 @@ "links": {} } }, - "summary": "Patch a facility metadata field definition", - "description": "Partially updates one metadata field definition.", + "summary": "Patch facility", + "description": "Partially updates facility information.", "parameters": [ { "name": "organizationId", "in": "path", - "description": "FacilityMetadataField identifier", + "description": "LegacyFacility identifier", "required": true, "deprecated": false, "schema": { @@ -16854,9 +17086,9 @@ "explode": false }, { - "name": "id", + "name": "facilityId", "in": "path", - "description": "FacilityMetadataField identifier", + "description": "LegacyFacility identifier", "required": true, "deprecated": false, "schema": { @@ -16867,11 +17099,11 @@ } ], "requestBody": { - "description": "The updated FacilityMetadataField resource", + "description": "The updated LegacyFacility resource", "content": { "application/merge-patch+json": { "schema": { - "$ref": "#/components/schemas/FacilityMetadataField.UpdateFacilityMetadataFieldInput-facility.write.jsonMergePatch" + "$ref": "#/components/schemas/LegacyFacility.UpdateFacilityInput-facility.write.jsonMergePatch" } } }, @@ -16879,25 +17111,26 @@ } } }, - "/api/organizations/{organizationId}/facility-tree": { - "get": { - "operationId": "getFacilityTree", + "/api/organizations/{organizationId}/facilities/{facilityId}/archive": { + "post": { + "operationId": "facility_archive", "tags": [ - "FacilityTree" + "Facility" ], "responses": { "200": { - "description": "FacilityTree resource", + "description": "Facility archived", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/FacilityTree.FacilityTreeOutput.jsonld" + "$ref": "#/components/schemas/LegacyFacility.FacilityOutput.jsonld-facility.read" } } - } + }, + "links": {} }, - "403": { - "description": "Forbidden", + "400": { + "description": "Invalid identifier", "content": { "application/ld+json": { "schema": { @@ -16917,35 +17150,53 @@ }, "links": {} }, + "403": { + "description": "Insufficient permissions" + }, "404": { - "description": "Not found", + "description": "Facility not found" + }, + "422": { + "description": "An error occurred", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Error.jsonld" + "$ref": "#/components/schemas/ConstraintViolation.jsonld" } }, "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/ConstraintViolation" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/ConstraintViolation" } } }, "links": {} } }, - "summary": "Retrieves a FacilityTree resource.", - "description": "Retrieves a FacilityTree resource.", + "summary": "Archive facility", + "description": "Archives one facility.", "parameters": [ { "name": "organizationId", "in": "path", - "description": "FacilityTree identifier", + "description": "LegacyFacility identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "facilityId", + "in": "path", + "description": "LegacyFacility identifier", "required": true, "deprecated": false, "schema": { @@ -16954,70 +17205,81 @@ "style": "simple", "explode": false } - ] + ], + "deprecated": true } }, - "/api/health": { + "/api/organizations/{organizationId}/facilities/{facilityId}/building-model": { "get": { - "operationId": "health_check", + "operationId": "facility_building_model_get", "tags": [ - "Health" + "Facility" ], "responses": { "200": { - "description": "Application is healthy", + "description": "Building model retrieved", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Health.HealthOutput.jsonld-health.read_health.read.details" + "$ref": "#/components/schemas/LegacyFacility.FacilityBuildingModelOutput.jsonld-facility.read" } } } }, + "403": { + "description": "Insufficient permissions" + }, "404": { - "description": "Not found", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "links": {} + "description": "Facility not found" }, - "503": { - "description": "Application is unhealthy - critical dependency failure" + "409": { + "description": "Facility is not a building" } }, - "summary": "Health Check", - "description": "Returns the health status of the application and its dependencies (database, cache).", - "parameters": [] + "summary": "Get facility 3D building model", + "description": "Assembles, for a `building` facility, the ordered stack of floors a 3D viewer extrudes — each floor's outline and its rooms. A building with no floors, a floor with no primary plan, or a floor with no room are all valid \"200\" shapes, never errors.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "LegacyFacility identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "facilityId", + "in": "path", + "description": "LegacyFacility identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ] } }, - "/api/imports": { + "/api/organizations/{organizationId}/facilities/{facilityId}/children": { "get": { - "operationId": "api_imports_get_collection", + "operationId": "facility_list_children", "tags": [ - "ImportJob" + "Facility" ], "responses": { "200": { - "description": "ImportJob collection", + "description": "Facility children retrieved", "content": { "application/ld+json": { "schema": { "type": "object", - "description": "ImportJob.ImportJobOutput.jsonld collection.", + "description": "LegacyFacility.FacilityOutput.jsonld-facility.read collection.", "allOf": [ { "$ref": "#/components/schemas/HydraCollectionBaseSchema" @@ -17031,7 +17293,7 @@ "member": { "type": "array", "items": { - "$ref": "#/components/schemas/ImportJob.ImportJobOutput.jsonld" + "$ref": "#/components/schemas/LegacyFacility.FacilityOutput.jsonld-facility.read" } } } @@ -17041,31 +17303,43 @@ } } }, + "400": { + "description": "Invalid identifier" + }, "403": { - "description": "Forbidden", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "links": {} + "description": "Insufficient permissions" + }, + "404": { + "description": "Facility not found" } }, - "summary": "Retrieves the collection of ImportJob resources.", - "description": "Retrieves the collection of ImportJob resources.", + "summary": "List facility children", + "description": "Lists direct children for one facility. This is the lazy tree expansion endpoint.", "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "LegacyFacility identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "facilityId", + "in": "path", + "description": "LegacyFacility identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, { "name": "page", "in": "query", @@ -17089,27 +17363,168 @@ "type": "integer", "default": 30, "minimum": 0, - "maximum": 100 + "maximum": 200 }, "style": "form", "explode": true }, { - "name": "organization", + "name": "includePath", "in": "query", - "description": "Organization IRI.", + "description": "Resolve ancestor breadcrumbs for this page in one organization-scoped read.", + "required": false, + "deprecated": false, + "schema": { + "type": "boolean", + "default": false + }, + "style": "form", + "explode": true + }, + { + "name": "includeArchived", + "in": "query", + "description": "When true, archived children are included.", + "required": false, + "deprecated": false, + "schema": { + "type": "boolean", + "default": false + }, + "style": "form", + "explode": true + }, + { + "name": "search", + "in": "query", + "description": "Text search across child facilities.", + "required": false, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "form", + "explode": true + } + ] + } + }, + "/api/organizations/{organizationId}/facilities/{facilityId}/descendants": { + "get": { + "operationId": "facility_list_descendants", + "tags": [ + "Facility" + ], + "responses": { + "200": { + "description": "Facility descendants retrieved", + "content": { + "application/ld+json": { + "schema": { + "type": "object", + "description": "LegacyFacility.FacilityOutput.jsonld-facility.read collection.", + "allOf": [ + { + "$ref": "#/components/schemas/HydraCollectionBaseSchemaNoPagination" + }, + { + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LegacyFacility.FacilityOutput.jsonld-facility.read" + } + } + } + } + ] + } + } + } + }, + "400": { + "description": "Invalid identifier" + }, + "403": { + "description": "Insufficient permissions" + }, + "404": { + "description": "Facility not found" + } + }, + "summary": "List facility descendants", + "description": "Lists descendants for one facility. Set pagination=true to read bounded pages with search and a total count.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "LegacyFacility identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "facilityId", + "in": "path", + "description": "LegacyFacility identifier", "required": true, "deprecated": false, "schema": { "type": "string" }, + "style": "simple", + "explode": false + }, + { + "name": "pagination", + "in": "query", + "description": "Enable or disable pagination", + "required": false, + "deprecated": false, + "schema": { + "type": "boolean" + }, "style": "form", "explode": true }, { - "name": "kind", + "name": "includePath", "in": "query", - "description": "Resource kind filter (equipment|facility|member).", + "description": "Resolve ancestor breadcrumbs for this page in one organization-scoped read.", + "required": false, + "deprecated": false, + "schema": { + "type": "boolean", + "default": false + }, + "style": "form", + "explode": true + }, + { + "name": "includeArchived", + "in": "query", + "description": "When true, archived descendants are included.", + "required": false, + "deprecated": false, + "schema": { + "type": "boolean", + "default": false + }, + "style": "form", + "explode": true + }, + { + "name": "search", + "in": "query", + "description": "Text search across descendant facilities.", "required": false, "deprecated": false, "schema": { @@ -17119,26 +17534,28 @@ "explode": true } ] - }, + } + }, + "/api/organizations/{organizationId}/facilities/{facilityId}/duplicate": { "post": { - "operationId": "api_imports_post", + "operationId": "facility_duplicate_subtree", "tags": [ - "Import" + "Facility" ], "responses": { - "202": { - "description": "Import job accepted, processing enqueued", + "201": { + "description": "Facility subtree duplicated", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/ImportJob.ImportJobOutput.jsonld" + "$ref": "#/components/schemas/LegacyFacility.FacilityOutput.jsonld-facility.read" } } }, "links": {} }, "400": { - "description": "Invalid input", + "description": "Invalid input or target parent", "content": { "application/ld+json": { "schema": { @@ -17161,8 +17578,14 @@ "403": { "description": "Insufficient permissions" }, + "404": { + "description": "Facility not found" + }, + "409": { + "description": "Source facility is archived, or the organization plan quota would be exceeded" + }, "422": { - "description": "MIME type, extension or size rejected", + "description": "Subtree exceeds the duplication size limit", "content": { "application/ld+json": { "schema": { @@ -17183,68 +17606,67 @@ "links": {} } }, - "summary": "Upload a bulk CSV import", - "description": "Uploads a CSV file to provision Equipment or Facility resources, or member invitations, in bulk; a row failure is reported, not fatal to the batch.", - "parameters": [], + "summary": "Duplicate facility subtree", + "description": "Duplicates a facility and its full subtree into a new, independent branch.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "LegacyFacility identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "facilityId", + "in": "path", + "description": "LegacyFacility identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], "requestBody": { + "description": "The new LegacyFacility resource", "content": { - "multipart/form-data": { + "application/ld+json": { "schema": { - "type": "object", - "properties": { - "organization": { - "type": "string", - "description": "Organization IRI." - }, - "kind": { - "type": "string", - "enum": [ - "equipment", - "facility", - "member" - ] - }, - "file": { - "type": "string", - "format": "binary" - }, - "dryRun": { - "type": "boolean", - "description": "Validate, resolve parent-by-code and project the plan quota without provisioning anything; the report is returned via GET /imports/{id}." - } - }, - "required": [ - "organization", - "kind", - "file" - ] + "$ref": "#/components/schemas/LegacyFacility.DuplicateFacilitySubtreeInput-facility.write" } } }, - "required": false + "required": true } } }, - "/api/imports/{id}/confirm": { + "/api/organizations/{organizationId}/facilities/{facilityId}/move": { "post": { - "operationId": "confirm_import_simulation", + "operationId": "facility_move", "tags": [ - "Import" + "Facility" ], "responses": { - "202": { - "description": "The single real import, newly enqueued or already confirmed", + "200": { + "description": "Facility moved", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/ImportJob.ImportJobOutput.jsonld" + "$ref": "#/components/schemas/LegacyFacility.FacilityOutput.jsonld-facility.read" } } }, "links": {} }, "400": { - "description": "Invalid input", + "description": "Invalid request", "content": { "application/ld+json": { "schema": { @@ -17265,16 +17687,16 @@ "links": {} }, "403": { - "description": "Write permission removed" + "description": "Insufficient permissions" }, "404": { - "description": "Simulation absent or outside membership scope" + "description": "Facility not found" }, - "409": { - "description": "Simulation incomplete, contains row failures, or retained file unavailable" + "412": { + "description": "Stale revision" }, "422": { - "description": "An error occurred", + "description": "Invalid hierarchy", "content": { "application/ld+json": { "schema": { @@ -17293,15 +17715,43 @@ } }, "links": {} + }, + "428": { + "description": "If-Match is required" } }, - "summary": "Confirm a successful simulation once", - "description": "Reuses the retained file. Repeated confirmation returns the same real import. Rights, quotas and references are checked again during execution.", + "summary": "Move facility", + "description": "Moves a facility under a valid parent using its current revision.", "parameters": [ { - "name": "id", + "name": "If-Match", + "in": "header", + "description": "The current facility revision, for example \"revision-1\".", + "required": true, + "deprecated": false, + "schema": { + "type": "string", + "pattern": "^\"revision-[0-9]+\"$" + }, + "style": "simple", + "explode": false + }, + { + "name": "organizationId", "in": "path", - "description": "ImportJob identifier", + "description": "LegacyFacility identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "facilityId", + "in": "path", + "description": "LegacyFacility identifier", "required": true, "deprecated": false, "schema": { @@ -17310,29 +17760,40 @@ "style": "simple", "explode": false } - ] + ], + "requestBody": { + "description": "The new LegacyFacility resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/LegacyFacility.MoveFacilityInput-facility.write" + } + } + }, + "required": true + } } }, - "/api/imports/{id}/resume": { - "post": { - "operationId": "resume_import_job", + "/api/organizations/{organizationId}/facilities/{facilityId}/plan-geometry": { + "put": { + "operationId": "facility_plan_geometry_set", "tags": [ - "Import" + "Facility" ], "responses": { - "202": { - "description": "Existing import enqueued; confirmed rows retained", + "200": { + "description": "Plan geometry set or cleared", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/ImportJob.ImportJobOutput.jsonld" + "$ref": "#/components/schemas/LegacyFacility.FacilityOutput.jsonld-facility.read" } } }, "links": {} }, "400": { - "description": "Invalid input", + "description": "Invalid geometry shape, bounds, or point count", "content": { "application/ld+json": { "schema": { @@ -17353,13 +17814,13 @@ "links": {} }, "403": { - "description": "Missing write permission for this import kind" + "description": "Insufficient permissions" }, "404": { - "description": "Import absent or outside the active membership scope" + "description": "Facility or attachment not found" }, "409": { - "description": "Import already completed or reserved by a live worker" + "description": "Attachment is not a floor plan, or does not belong to this facility or an ancestor" }, "422": { "description": "An error occurred", @@ -17383,13 +17844,25 @@ "links": {} } }, - "summary": "Resume the same import from its confirmed rows", - "description": "Resume the same import from its confirmed rows", + "summary": "Set facility plan geometry", + "description": "Binds this facility (a spatial zone) to a polygon drawn over an ancestor floor plan attachment. Submit \"attachmentId\" and \"points\" (>=3 normalized 0-1 coordinates) together to set or replace the geometry, or both null to clear it.", "parameters": [ { - "name": "id", + "name": "organizationId", "in": "path", - "description": "ImportJob identifier", + "description": "LegacyFacility identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "facilityId", + "in": "path", + "description": "LegacyFacility identifier", "required": true, "deprecated": false, "schema": { @@ -17398,40 +17871,57 @@ "style": "simple", "explode": false } - ] + ], + "requestBody": { + "description": "The updated LegacyFacility resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/LegacyFacility.SetFacilityPlanGeometryInput-facility.write" + } + } + }, + "required": true + } } }, - "/api/organizations/{organizationId}/import-templates/{kind}": { + "/api/organizations/{organizationId}/facilities/{facilityId}/plan-overlay": { "get": { - "operationId": "get_import_template", + "operationId": "facility_plan_overlay_get", "tags": [ - "Import" + "Facility" ], "responses": { "200": { - "description": "Filename, CSV content and media type", + "description": "Plan overlay retrieved", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/ImportTemplate.ImportTemplateOutput.jsonld" + "$ref": "#/components/schemas/LegacyFacility.FacilityPlanOverlayOutput.jsonld-facility.read" } } } }, + "400": { + "description": "Invalid identifier" + }, "403": { - "description": "Missing write permission for this kind" + "description": "Insufficient permissions" }, "404": { - "description": "Unknown kind or organization outside membership scope" + "description": "Facility or attachment not found" + }, + "409": { + "description": "Attachment is not a floor plan, or does not belong to this facility or an ancestor" } }, - "summary": "Download the CSV template for an authorized import kind", - "description": "Download the CSV template for an authorized import kind", + "summary": "Get facility plan overlay", + "description": "Returns one floor plan (explicit \"attachmentId\", or this facility's own primary plan when omitted) and every published self-or-descendant zone bound to it.", "parameters": [ { "name": "organizationId", "in": "path", - "description": "ImportTemplate identifier", + "description": "LegacyFacility identifier", "required": true, "deprecated": false, "schema": { @@ -17441,9 +17931,9 @@ "explode": false }, { - "name": "kind", + "name": "facilityId", "in": "path", - "description": "ImportTemplate identifier", + "description": "LegacyFacility identifier", "required": true, "deprecated": false, "schema": { @@ -17451,29 +17941,43 @@ }, "style": "simple", "explode": false - } - ] - } - }, - "/api/imports/{id}": { - "get": { - "operationId": "api_imports_id_get", + }, + { + "name": "attachmentId", + "in": "query", + "description": "Floor plan attachment identifier. Defaults to this facility's primary plan when omitted.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "uuid" + }, + "style": "form", + "explode": true + } + ] + } + }, + "/api/organizations/{organizationId}/facilities/{facilityId}/restore": { + "patch": { + "operationId": "facility_restore", "tags": [ - "ImportJob" + "Facility" ], "responses": { "200": { - "description": "ImportJob resource", + "description": "Facility restored", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/ImportJob.ImportJobOutput.jsonld" + "$ref": "#/components/schemas/LegacyFacility.FacilityOutput.jsonld-facility.read" } } - } + }, + "links": {} }, - "403": { - "description": "Forbidden", + "400": { + "description": "Invalid identifier or parent state", "content": { "application/ld+json": { "schema": { @@ -17493,35 +17997,41 @@ }, "links": {} }, + "403": { + "description": "Insufficient permissions" + }, "404": { - "description": "Not found", + "description": "Facility not found" + }, + "422": { + "description": "An error occurred", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Error.jsonld" + "$ref": "#/components/schemas/ConstraintViolation.jsonld" } }, "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/ConstraintViolation" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/ConstraintViolation" } } }, "links": {} } }, - "summary": "Retrieves a ImportJob resource.", - "description": "Retrieves a ImportJob resource.", + "summary": "Restore facility", + "description": "Restores an archived facility.", "parameters": [ { - "name": "id", + "name": "organizationId", "in": "path", - "description": "ImportJob identifier", + "description": "LegacyFacility identifier", "required": true, "deprecated": false, "schema": { @@ -17531,60 +18041,103 @@ "explode": false }, { - "name": "reportPage", - "in": "query", - "description": "Report page, starting at one.", - "required": false, - "deprecated": false, - "schema": { - "type": "integer", - "minimum": 1 - }, - "style": "form", - "explode": true - }, - { - "name": "reportItemsPerPage", - "in": "query", - "description": "Report rows per page, from one to 100 (default 100).", - "required": false, + "name": "facilityId", + "in": "path", + "description": "LegacyFacility identifier", + "required": true, "deprecated": false, "schema": { - "type": "integer", - "minimum": 1, - "maximum": 100 + "type": "string" }, - "style": "form", - "explode": true + "style": "simple", + "explode": false } ] } }, - "/api/inbox": { + "/api/organizations/{organizationId}/facility-metadata-fields": { "get": { - "operationId": "inbox_get", + "operationId": "facility_metadata_field_list", "tags": [ - "Inbox" + "Facility" ], "responses": { "200": { - "description": "Inbox feed retrieved successfully", + "description": "Metadata field definitions retrieved", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Inbox.InboxOutput.jsonld-notification.inbox.read" + "type": "object", + "description": "FacilityMetadataField.FacilityMetadataFieldOutput.jsonld-facility.read collection.", + "allOf": [ + { + "$ref": "#/components/schemas/HydraCollectionBaseSchemaNoPagination" + }, + { + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FacilityMetadataField.FacilityMetadataFieldOutput.jsonld-facility.read" + } + } + } + } + ] } } } }, "400": { - "description": "Invalid or conflicting cursor parameters" - }, - "401": { - "description": "Authentication required" + "description": "Invalid organization identifier" }, "403": { - "description": "Forbidden", + "description": "Insufficient permissions" + }, + "404": { + "description": "Organization not found" + } + }, + "summary": "List facility metadata field definitions", + "description": "Lists the organization's typed metadata field definitions. Doubles as the frontend's form-schema source.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "FacilityMetadataField identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ] + }, + "post": { + "operationId": "facility_metadata_field_create", + "tags": [ + "Facility" + ], + "responses": { + "201": { + "description": "Metadata field created", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/FacilityMetadataField.FacilityMetadataFieldOutput.jsonld-facility.read" + } + } + }, + "links": {} + }, + "400": { + "description": "Invalid input", "content": { "application/ld+json": { "schema": { @@ -17604,113 +18157,131 @@ }, "links": {} }, + "403": { + "description": "Insufficient permissions" + }, "404": { - "description": "Not found", + "description": "Organization not found" + }, + "409": { + "description": "Metadata field key already exists for this organization" + }, + "422": { + "description": "Organization has reached the metadata field definition cap (50)", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Error.jsonld" + "$ref": "#/components/schemas/ConstraintViolation.jsonld" } }, "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/ConstraintViolation" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/ConstraintViolation" } } }, "links": {} } }, - "summary": "Get the unified inbox feed", - "description": "Merges account notifications and, when an organization is selected, authorized Messaging mentions. Send nextPageCursor unchanged as cursor to continue the timestamp/source/id ordering without skipping ties. If complete is false, retry the current page; no nextPageCursor is issued while a source is unavailable. Legacy before/nextCursor remain supported but do not preserve timestamp ties. Do not combine before and cursor.", + "summary": "Create a facility metadata field definition", + "description": "Creates one typed metadata field definition for the organization.", "parameters": [ { - "name": "cursor", - "in": "query", - "description": "Opaque nextPageCursor from the previous response; preserves timestamp ties across all sources. Mutually exclusive with before.", - "required": false, + "name": "organizationId", + "in": "path", + "description": "FacilityMetadataField identifier", + "required": true, "deprecated": false, "schema": { - "type": "string", - "maxLength": 2048 + "type": "string" }, - "style": "form", - "explode": true + "style": "simple", + "explode": false + } + ], + "requestBody": { + "description": "The new FacilityMetadataField resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/FacilityMetadataField.CreateFacilityMetadataFieldInput-facility.write" + } + } }, - { - "name": "organization", - "in": "query", - "description": "Filter by organization identifier. When omitted, items across all organizations (and account-level ones) are returned.", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "format": "uuid" - }, - "style": "form", - "explode": true + "required": true + } + } + }, + "/api/organizations/{organizationId}/facility-metadata-fields/{id}": { + "delete": { + "operationId": "facility_metadata_field_delete", + "tags": [ + "Facility" + ], + "responses": { + "204": { + "description": "Metadata field deleted" + }, + "403": { + "description": "Insufficient permissions" }, + "404": { + "description": "Metadata field not found" + } + }, + "summary": "Delete a facility metadata field definition", + "description": "Deletes one metadata field definition. Existing facility metadata values for this key are left untouched.", + "parameters": [ { - "name": "before", - "in": "query", - "description": "Cursor: only items that occurred strictly before this ISO-8601 instant are returned. Omit for the first page; pass the previous response's `nextCursor` to fetch the next page.", - "required": false, + "name": "organizationId", + "in": "path", + "description": "FacilityMetadataField identifier", + "required": true, "deprecated": false, "schema": { - "type": "string", - "format": "date-time" + "type": "string" }, - "style": "form", - "explode": true + "style": "simple", + "explode": false }, { - "name": "limit", - "in": "query", - "description": "Maximum number of items to return (clamped server-side between 1 and 50).", - "required": false, + "name": "id", + "in": "path", + "description": "FacilityMetadataField identifier", + "required": true, "deprecated": false, "schema": { - "type": "integer", - "default": 20 + "type": "string" }, - "style": "form", - "explode": true - } - ], - "security": [ - { - "bearerAuth": [] + "style": "simple", + "explode": false } ] - } - }, - "/api/inbox/unread-count": { - "get": { - "operationId": "inbox_unread_count", + }, + "patch": { + "operationId": "facility_metadata_field_update", "tags": [ - "Inbox" + "Facility" ], "responses": { "200": { - "description": "Unread inbox count returned successfully", + "description": "Metadata field updated", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Inbox.InboxUnreadCountOutput.jsonld-notification.inbox.unread_count" + "$ref": "#/components/schemas/FacilityMetadataField.FacilityMetadataFieldOutput.jsonld-facility.read" } } - } - }, - "401": { - "description": "Authentication required" + }, + "links": {} }, - "403": { - "description": "Forbidden", + "400": { + "description": "Invalid input", "content": { "application/ld+json": { "schema": { @@ -17730,83 +18301,142 @@ }, "links": {} }, + "403": { + "description": "Insufficient permissions" + }, "404": { - "description": "Not found", + "description": "Metadata field not found" + }, + "422": { + "description": "An error occurred", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Error.jsonld" + "$ref": "#/components/schemas/ConstraintViolation.jsonld" } }, "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/ConstraintViolation" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/ConstraintViolation" } } }, "links": {} } }, - "summary": "Get the unified inbox unread count", - "description": "Returns the unread item count summed across every registered inbox source for the authenticated user (currently: their own notifications — the same figure `GET /notifications/unread-count` already exposes, but sourced through the unified inbox seam so it stays correct once Messaging registers mentions/direct messages/thread replies as additional sources). Intended for a persistent UI chrome badge, unlike the paginated `GET /inbox` feed.", + "summary": "Patch a facility metadata field definition", + "description": "Partially updates one metadata field definition.", "parameters": [ { - "name": "organization", - "in": "query", - "description": "Filter by organization identifier. When omitted, unread items across all organizations (and account-level ones) are counted.", - "required": false, + "name": "organizationId", + "in": "path", + "description": "FacilityMetadataField identifier", + "required": true, "deprecated": false, "schema": { - "type": "string", - "format": "uuid" + "type": "string" }, - "style": "form", - "explode": true - } - ], - "security": [ + "style": "simple", + "explode": false + }, { - "bearerAuth": [] + "name": "id", + "in": "path", + "description": "FacilityMetadataField identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false } - ] + ], + "requestBody": { + "description": "The updated FacilityMetadataField resource", + "content": { + "application/merge-patch+json": { + "schema": { + "$ref": "#/components/schemas/FacilityMetadataField.UpdateFacilityMetadataFieldInput-facility.write.jsonMergePatch" + } + } + }, + "required": true + } } }, - "/api/inspection-attachments/{id}": { + "/api/organizations/{organizationId}/facility-tree": { "get": { - "operationId": "get_inspection_attachment", + "operationId": "getFacilityTree", "tags": [ - "Inspection" + "FacilityTree" ], "responses": { "200": { - "description": "Attachment retrieved", + "description": "FacilityTree resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/InspectionAttachment.InspectionAttachmentOutput.jsonld-inspection.read" + "$ref": "#/components/schemas/FacilityTree.FacilityTreeOutput.jsonld" } } } }, "403": { - "description": "Insufficient permissions" + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} }, "404": { - "description": "Attachment not found" + "description": "Not found", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} } }, - "summary": "Get an inspection attachment", - "description": "Get an inspection attachment", + "summary": "Retrieves a FacilityTree resource.", + "description": "Retrieves a FacilityTree resource.", "parameters": [ { - "name": "id", + "name": "organizationId", "in": "path", - "description": "InspectionAttachment identifier", + "description": "FacilityTree identifier", "required": true, "deprecated": false, "schema": { @@ -17816,61 +18446,69 @@ "explode": false } ] - }, - "delete": { - "operationId": "delete_inspection_attachment", + } + }, + "/api/health": { + "get": { + "operationId": "health_check", "tags": [ - "Inspection" + "Health" ], "responses": { - "204": { - "description": "Attachment deleted" - }, - "403": { - "description": "Insufficient permissions" + "200": { + "description": "Application is healthy", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Health.HealthOutput.jsonld-health.read_health.read.details" + } + } + } }, "404": { - "description": "Attachment not found" - }, - "412": { - "description": "Attachment revision is stale" + "description": "Not found", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} }, - "428": { - "description": "If-Match header is required" + "503": { + "description": "Application is unhealthy - critical dependency failure" } }, - "summary": "Delete an inspection attachment", - "description": "Delete an inspection attachment", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "InspectionAttachment identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - } - ] + "summary": "Health Check", + "description": "Returns the health status of the application and its dependencies (database, cache).", + "parameters": [] } }, - "/api/inspections": { + "/api/imports": { "get": { - "operationId": "api_inspections_get_collection", + "operationId": "api_imports_get_collection", "tags": [ - "Inspection" + "ImportJob" ], "responses": { "200": { - "description": "Inspection collection", + "description": "ImportJob collection", "content": { "application/ld+json": { "schema": { "type": "object", - "description": "Inspection.InspectionOutput.jsonld collection.", + "description": "ImportJob.ImportJobOutput.jsonld collection.", "allOf": [ { "$ref": "#/components/schemas/HydraCollectionBaseSchema" @@ -17884,7 +18522,7 @@ "member": { "type": "array", "items": { - "$ref": "#/components/schemas/Inspection.InspectionOutput.jsonld" + "$ref": "#/components/schemas/ImportJob.ImportJobOutput.jsonld" } } } @@ -17916,8 +18554,8 @@ "links": {} } }, - "summary": "Retrieves the collection of Inspection resources.", - "description": "Retrieves the collection of Inspection resources.", + "summary": "Retrieves the collection of ImportJob resources.", + "description": "Retrieves the collection of ImportJob resources.", "parameters": [ { "name": "page", @@ -17940,7 +18578,7 @@ "deprecated": false, "schema": { "type": "integer", - "default": 50, + "default": 30, "minimum": 0, "maximum": 100 }, @@ -17950,20 +18588,8 @@ { "name": "organization", "in": "query", - "description": "Organization IRI. Required when intervention is omitted.", - "required": false, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "form", - "explode": true - }, - { - "name": "intervention", - "in": "query", - "description": "Intervention IRI. Also scopes the organization and defaults recordStatus to draft.", - "required": false, + "description": "Organization IRI.", + "required": true, "deprecated": false, "schema": { "type": "string" @@ -17972,9 +18598,9 @@ "explode": true }, { - "name": "equipment", + "name": "kind", "in": "query", - "description": "Equipment IRI.", + "description": "Resource kind filter (equipment|facility|member).", "required": false, "deprecated": false, "schema": { @@ -17982,37 +18608,21 @@ }, "style": "form", "explode": true - }, - { - "name": "recordStatus", - "in": "query", - "description": "Lifecycle status of the representation.", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "enum": [ - "draft", - "published" - ] - }, - "style": "form", - "explode": true } ] }, "post": { - "operationId": "api_inspections_post", + "operationId": "api_imports_post", "tags": [ - "Inspection" + "Import" ], "responses": { - "201": { - "description": "Inspection resource created", + "202": { + "description": "Import job accepted, processing enqueued", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Inspection.InspectionOutput.jsonld" + "$ref": "#/components/schemas/ImportJob.ImportJobOutput.jsonld" } } }, @@ -18040,28 +18650,10 @@ "links": {} }, "403": { - "description": "Forbidden", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "links": {} + "description": "Insufficient permissions" }, "422": { - "description": "An error occurred", + "description": "MIME type, extension or size rejected", "content": { "application/ld+json": { "schema": { @@ -18082,41 +18674,68 @@ "links": {} } }, - "summary": "Creates a Inspection resource.", - "description": "Creates a Inspection resource.", + "summary": "Upload a bulk CSV import", + "description": "Uploads a CSV file to provision Equipment or Facility resources, or member invitations, in bulk; a row failure is reported, not fatal to the batch.", "parameters": [], "requestBody": { - "description": "The new Inspection resource", "content": { - "application/ld+json": { + "multipart/form-data": { "schema": { - "$ref": "#/components/schemas/Inspection.CreateInspectionInput" + "type": "object", + "properties": { + "organization": { + "type": "string", + "description": "Organization IRI." + }, + "kind": { + "type": "string", + "enum": [ + "equipment", + "facility", + "member" + ] + }, + "file": { + "type": "string", + "format": "binary" + }, + "dryRun": { + "type": "boolean", + "description": "Validate, resolve parent-by-code and project the plan quota without provisioning anything; the report is returned via GET /imports/{id}." + } + }, + "required": [ + "organization", + "kind", + "file" + ] } } }, - "required": true + "required": false } } }, - "/api/inspections/{id}": { - "get": { - "operationId": "api_inspections_id_get", + "/api/imports/{id}/confirm": { + "post": { + "operationId": "confirm_import_simulation", "tags": [ - "Inspection" + "Import" ], "responses": { - "200": { - "description": "Inspection resource", + "202": { + "description": "The single real import, newly enqueued or already confirmed", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Inspection.InspectionOutput.jsonld" + "$ref": "#/components/schemas/ImportJob.ImportJobOutput.jsonld" } } - } + }, + "links": {} }, - "403": { - "description": "Forbidden", + "400": { + "description": "Invalid input", "content": { "application/ld+json": { "schema": { @@ -18136,35 +18755,44 @@ }, "links": {} }, + "403": { + "description": "Write permission removed" + }, "404": { - "description": "Not found", + "description": "Simulation absent or outside membership scope" + }, + "409": { + "description": "Simulation incomplete, contains row failures, or retained file unavailable" + }, + "422": { + "description": "An error occurred", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Error.jsonld" + "$ref": "#/components/schemas/ConstraintViolation.jsonld" } }, "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/ConstraintViolation" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/ConstraintViolation" } } }, "links": {} } }, - "summary": "Retrieves a Inspection resource.", - "description": "Retrieves a Inspection resource.", + "summary": "Confirm a successful simulation once", + "description": "Reuses the retained file. Repeated confirmation returns the same real import. Rights, quotas and references are checked again during execution.", "parameters": [ { "name": "id", "in": "path", - "description": "Inspection identifier", + "description": "ImportJob identifier", "required": true, "deprecated": false, "schema": { @@ -18174,19 +18802,21 @@ "explode": false } ] - }, - "put": { - "operationId": "canonical_inspection_put", + } + }, + "/api/imports/{id}/resume": { + "post": { + "operationId": "resume_import_job", "tags": [ - "Inspection" + "Import" ], "responses": { - "201": { - "description": "Inspection resource updated", + "202": { + "description": "Existing import enqueued; confirmed rows retained", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Inspection.InspectionOutput.jsonld" + "$ref": "#/components/schemas/ImportJob.ImportJobOutput.jsonld" } } }, @@ -18214,46 +18844,13 @@ "links": {} }, "403": { - "description": "Forbidden", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "links": {} + "description": "Missing write permission for this import kind" }, "404": { - "description": "Not found", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "links": {} + "description": "Import absent or outside the active membership scope" + }, + "409": { + "description": "Import already completed or reserved by a live worker" }, "422": { "description": "An error occurred", @@ -18277,13 +18874,13 @@ "links": {} } }, - "summary": "Replaces the Inspection resource.", - "description": "Replaces the Inspection resource.", + "summary": "Resume the same import from its confirmed rows", + "description": "Resume the same import from its confirmed rows", "parameters": [ { "name": "id", "in": "path", - "description": "Inspection identifier", + "description": "ImportJob identifier", "required": true, "deprecated": false, "schema": { @@ -18292,27 +18889,79 @@ "style": "simple", "explode": false } + ] + } + }, + "/api/organizations/{organizationId}/import-templates/{kind}": { + "get": { + "operationId": "get_import_template", + "tags": [ + "Import" ], - "requestBody": { - "description": "The updated Inspection resource", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Inspection.CreateInspectionInput" + "responses": { + "200": { + "description": "Filename, CSV content and media type", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ImportTemplate.ImportTemplateOutput.jsonld" + } } } }, - "required": true - } - }, - "delete": { - "operationId": "canonical_inspection_delete", + "403": { + "description": "Missing write permission for this kind" + }, + "404": { + "description": "Unknown kind or organization outside membership scope" + } + }, + "summary": "Download the CSV template for an authorized import kind", + "description": "Download the CSV template for an authorized import kind", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "ImportTemplate identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "kind", + "in": "path", + "description": "ImportTemplate identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ] + } + }, + "/api/imports/{id}": { + "get": { + "operationId": "api_imports_id_get", "tags": [ - "Inspection" + "ImportJob" ], "responses": { - "204": { - "description": "Inspection resource deleted" + "200": { + "description": "ImportJob resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ImportJob.ImportJobOutput.jsonld" + } + } + } }, "403": { "description": "Forbidden", @@ -18357,13 +19006,13 @@ "links": {} } }, - "summary": "Removes the Inspection resource.", - "description": "Removes the Inspection resource.", + "summary": "Retrieves a ImportJob resource.", + "description": "Retrieves a ImportJob resource.", "parameters": [ { "name": "id", "in": "path", - "description": "Inspection identifier", + "description": "ImportJob identifier", "required": true, "deprecated": false, "schema": { @@ -18371,28 +19020,62 @@ }, "style": "simple", "explode": false + }, + { + "name": "reportPage", + "in": "query", + "description": "Report page, starting at one.", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "minimum": 1 + }, + "style": "form", + "explode": true + }, + { + "name": "reportItemsPerPage", + "in": "query", + "description": "Report rows per page, from one to 100 (default 100).", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100 + }, + "style": "form", + "explode": true } ] - }, - "patch": { - "operationId": "canonical_inspection_patch", + } + }, + "/api/inbox": { + "get": { + "operationId": "inbox_get", "tags": [ - "Inspection" + "Inbox" ], "responses": { "200": { - "description": "Inspection resource updated", + "description": "Inbox feed retrieved successfully", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Inspection.InspectionOutput.jsonld" + "$ref": "#/components/schemas/Inbox.InboxOutput.jsonld-notification.inbox.read" } } - }, - "links": {} + } }, "400": { - "description": "Invalid input", + "description": "Invalid or conflicting cursor parameters" + }, + "401": { + "description": "Authentication required" + }, + "403": { + "description": "Forbidden", "content": { "application/ld+json": { "schema": { @@ -18412,8 +19095,8 @@ }, "links": {} }, - "403": { - "description": "Forbidden", + "404": { + "description": "Not found", "content": { "application/ld+json": { "schema": { @@ -18432,9 +19115,93 @@ } }, "links": {} + } + }, + "summary": "Get the unified inbox feed", + "description": "Merges account notifications and, when an organization is selected, authorized Messaging mentions. Send nextPageCursor unchanged as cursor to continue the timestamp/source/id ordering without skipping ties. If complete is false, retry the current page; no nextPageCursor is issued while a source is unavailable. Legacy before/nextCursor remain supported but do not preserve timestamp ties. Do not combine before and cursor.", + "parameters": [ + { + "name": "cursor", + "in": "query", + "description": "Opaque nextPageCursor from the previous response; preserves timestamp ties across all sources. Mutually exclusive with before.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "maxLength": 2048 + }, + "style": "form", + "explode": true }, - "404": { - "description": "Not found", + { + "name": "organization", + "in": "query", + "description": "Filter by organization identifier. When omitted, items across all organizations (and account-level ones) are returned.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "uuid" + }, + "style": "form", + "explode": true + }, + { + "name": "before", + "in": "query", + "description": "Cursor: only items that occurred strictly before this ISO-8601 instant are returned. Omit for the first page; pass the previous response's `nextCursor` to fetch the next page.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "date-time" + }, + "style": "form", + "explode": true + }, + { + "name": "limit", + "in": "query", + "description": "Maximum number of items to return (clamped server-side between 1 and 50).", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 20 + }, + "style": "form", + "explode": true + } + ], + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/api/inbox/unread-count": { + "get": { + "operationId": "inbox_unread_count", + "tags": [ + "Inbox" + ], + "responses": { + "200": { + "description": "Unread inbox count returned successfully", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Inbox.InboxUnreadCountOutput.jsonld-notification.inbox.unread_count" + } + } + } + }, + "401": { + "description": "Authentication required" + }, + "403": { + "description": "Forbidden", "content": { "application/ld+json": { "schema": { @@ -18454,90 +19221,65 @@ }, "links": {} }, - "422": { - "description": "An error occurred", + "404": { + "description": "Not found", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation.jsonld" + "$ref": "#/components/schemas/Error.jsonld" } }, "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation" + "$ref": "#/components/schemas/Error" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation" + "$ref": "#/components/schemas/Error" } } }, "links": {} } }, - "summary": "Updates the Inspection resource.", - "description": "Updates the Inspection resource.", + "summary": "Get the unified inbox unread count", + "description": "Returns the unread item count summed across every registered inbox source for the authenticated user (currently: their own notifications — the same figure `GET /notifications/unread-count` already exposes, but sourced through the unified inbox seam so it stays correct once Messaging registers mentions/direct messages/thread replies as additional sources). Intended for a persistent UI chrome badge, unlike the paginated `GET /inbox` feed.", "parameters": [ { - "name": "id", - "in": "path", - "description": "Inspection identifier", - "required": true, + "name": "organization", + "in": "query", + "description": "Filter by organization identifier. When omitted, unread items across all organizations (and account-level ones) are counted.", + "required": false, "deprecated": false, "schema": { - "type": "string" + "type": "string", + "format": "uuid" }, - "style": "simple", - "explode": false + "style": "form", + "explode": true } ], - "requestBody": { - "description": "The updated Inspection resource", - "content": { - "application/merge-patch+json": { - "schema": { - "$ref": "#/components/schemas/Inspection.PatchCanonicalInspectionInput.jsonMergePatch" - } - } - }, - "required": true - } + "security": [ + { + "bearerAuth": [] + } + ] } }, - "/api/inspections/{inspectionId}/attachments": { + "/api/inspection-attachments/{id}": { "get": { - "operationId": "list_inspection_attachments", + "operationId": "get_inspection_attachment", "tags": [ "Inspection" ], "responses": { "200": { - "description": "Attachments retrieved", + "description": "Attachment retrieved", "content": { "application/ld+json": { "schema": { - "type": "object", - "description": "InspectionAttachment.InspectionAttachmentOutput.jsonld-inspection.read collection.", - "allOf": [ - { - "$ref": "#/components/schemas/HydraCollectionBaseSchema" - }, - { - "type": "object", - "required": [ - "member" - ], - "properties": { - "member": { - "type": "array", - "items": { - "$ref": "#/components/schemas/InspectionAttachment.InspectionAttachmentOutput.jsonld-inspection.read" - } - } - } - } - ] + "$ref": "#/components/schemas/InspectionAttachment.InspectionAttachmentOutput.jsonld-inspection.read" } } } @@ -18546,14 +19288,53 @@ "description": "Insufficient permissions" }, "404": { - "description": "Inspection not found" + "description": "Attachment not found" } }, - "summary": "List inspection attachments", - "description": "Lists the inspection-level attachments of an inspection.", + "summary": "Get an inspection attachment", + "description": "Get an inspection attachment", "parameters": [ { - "name": "inspectionId", + "name": "id", + "in": "path", + "description": "InspectionAttachment identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ] + }, + "delete": { + "operationId": "delete_inspection_attachment", + "tags": [ + "Inspection" + ], + "responses": { + "204": { + "description": "Attachment deleted" + }, + "403": { + "description": "Insufficient permissions" + }, + "404": { + "description": "Attachment not found" + }, + "412": { + "description": "Attachment revision is stale" + }, + "428": { + "description": "If-Match header is required" + } + }, + "summary": "Delete an inspection attachment", + "description": "Delete an inspection attachment", + "parameters": [ + { + "name": "id", "in": "path", "description": "InspectionAttachment identifier", "required": true, @@ -18563,7 +19344,72 @@ }, "style": "simple", "explode": false + } + ] + } + }, + "/api/inspections": { + "get": { + "operationId": "api_inspections_get_collection", + "tags": [ + "Inspection" + ], + "responses": { + "200": { + "description": "Inspection collection", + "content": { + "application/ld+json": { + "schema": { + "type": "object", + "description": "Inspection.InspectionOutput.jsonld collection.", + "allOf": [ + { + "$ref": "#/components/schemas/HydraCollectionBaseSchema" + }, + { + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Inspection.InspectionOutput.jsonld" + } + } + } + } + ] + } + } + } }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + } + }, + "summary": "Retrieves the collection of Inspection resources.", + "description": "Retrieves the collection of Inspection resources.", + "parameters": [ { "name": "page", "in": "query", @@ -18576,21 +19422,88 @@ }, "style": "form", "explode": true + }, + { + "name": "itemsPerPage", + "in": "query", + "description": "The number of items per page", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 50, + "minimum": 0, + "maximum": 100 + }, + "style": "form", + "explode": true + }, + { + "name": "organization", + "in": "query", + "description": "Organization IRI. Required when intervention is omitted.", + "required": false, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "form", + "explode": true + }, + { + "name": "intervention", + "in": "query", + "description": "Intervention IRI. Also scopes the organization and defaults recordStatus to draft.", + "required": false, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "form", + "explode": true + }, + { + "name": "equipment", + "in": "query", + "description": "Equipment IRI.", + "required": false, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "form", + "explode": true + }, + { + "name": "recordStatus", + "in": "query", + "description": "Lifecycle status of the representation.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "enum": [ + "draft", + "published" + ] + }, + "style": "form", + "explode": true } ] }, "post": { - "operationId": "add_inspection_attachment", + "operationId": "api_inspections_post", "tags": [ "Inspection" ], "responses": { "201": { - "description": "Attachment uploaded", + "description": "Inspection resource created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/InspectionAttachment.InspectionAttachmentOutput.jsonld-inspection.read" + "$ref": "#/components/schemas/Inspection.InspectionOutput.jsonld" } } }, @@ -18618,13 +19531,28 @@ "links": {} }, "403": { - "description": "Insufficient permissions" - }, - "404": { - "description": "Inspection not found" + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} }, "422": { - "description": "MIME type or size rejected", + "description": "An error occurred", "content": { "application/ld+json": { "schema": { @@ -18645,75 +19573,89 @@ "links": {} } }, - "summary": "Upload an inspection attachment", - "description": "Uploads a multipart file attachment to an inspection.", - "parameters": [ - { - "name": "inspectionId", - "in": "path", - "description": "InspectionAttachment identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - } - ] + "summary": "Creates a Inspection resource.", + "description": "Creates a Inspection resource.", + "parameters": [], + "requestBody": { + "description": "The new Inspection resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Inspection.CreateInspectionInput" + } + } + }, + "required": true + } } }, - "/api/non-conformities/{nonConformityId}/attachments": { + "/api/inspections/{id}": { "get": { - "operationId": "list_non_conformity_attachments", + "operationId": "api_inspections_id_get", "tags": [ "Inspection" ], "responses": { "200": { - "description": "Attachments retrieved", + "description": "Inspection resource", "content": { "application/ld+json": { "schema": { - "type": "object", - "description": "InspectionAttachment.InspectionAttachmentOutput.jsonld-inspection.read collection.", - "allOf": [ - { - "$ref": "#/components/schemas/HydraCollectionBaseSchema" - }, - { - "type": "object", - "required": [ - "member" - ], - "properties": { - "member": { - "type": "array", - "items": { - "$ref": "#/components/schemas/InspectionAttachment.InspectionAttachmentOutput.jsonld-inspection.read" - } - } - } - } - ] + "$ref": "#/components/schemas/Inspection.InspectionOutput.jsonld" } } } }, "403": { - "description": "Insufficient permissions" + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} }, "404": { - "description": "Non-conformity not found" + "description": "Not found", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} } }, - "summary": "List non-conformity field-proof photos", - "description": "Lists the field-proof photo attachments of a non-conformity.", + "summary": "Retrieves a Inspection resource.", + "description": "Retrieves a Inspection resource.", "parameters": [ { - "name": "nonConformityId", + "name": "id", "in": "path", - "description": "InspectionAttachment identifier", + "description": "Inspection identifier", "required": true, "deprecated": false, "schema": { @@ -18721,34 +19663,21 @@ }, "style": "simple", "explode": false - }, - { - "name": "page", - "in": "query", - "description": "The collection page number", - "required": false, - "deprecated": false, - "schema": { - "type": "integer", - "default": 1 - }, - "style": "form", - "explode": true } ] }, - "post": { - "operationId": "add_non_conformity_attachment", + "put": { + "operationId": "canonical_inspection_put", "tags": [ "Inspection" ], "responses": { "201": { - "description": "Attachment uploaded", + "description": "Inspection resource updated", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/InspectionAttachment.InspectionAttachmentOutput.jsonld-inspection.read" + "$ref": "#/components/schemas/Inspection.InspectionOutput.jsonld" } } }, @@ -18776,13 +19705,49 @@ "links": {} }, "403": { - "description": "Insufficient permissions" + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} }, "404": { - "description": "Non-conformity not found" + "description": "Not found", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} }, "422": { - "description": "MIME type or size rejected", + "description": "An error occurred", "content": { "application/ld+json": { "schema": { @@ -18803,13 +19768,13 @@ "links": {} } }, - "summary": "Upload a non-conformity field-proof photo", - "description": "Uploads a multipart file attachment (field-proof photo) to a non-conformity.", + "summary": "Replaces the Inspection resource.", + "description": "Replaces the Inspection resource.", "parameters": [ { - "name": "nonConformityId", + "name": "id", "in": "path", - "description": "InspectionAttachment identifier", + "description": "Inspection identifier", "required": true, "deprecated": false, "schema": { @@ -18818,57 +19783,78 @@ "style": "simple", "explode": false } - ] - } - }, - "/api/organizations/{organizationId}/facilities/{facilityId}/inspections": { - "get": { - "operationId": "facility_inspection_list", + ], + "requestBody": { + "description": "The updated Inspection resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Inspection.CreateInspectionInput" + } + } + }, + "required": true + } + }, + "delete": { + "operationId": "canonical_inspection_delete", "tags": [ "Inspection" ], "responses": { - "200": { - "description": "Facility inspection list", + "204": { + "description": "Inspection resource deleted" + }, + "403": { + "description": "Forbidden", "content": { "application/ld+json": { "schema": { - "type": "object", - "description": "LegacyInspection.InspectionOutput.jsonld-inspection.read collection.", - "allOf": [ - { - "$ref": "#/components/schemas/HydraCollectionBaseSchema" - }, - { - "type": "object", - "required": [ - "member" - ], - "properties": { - "member": { - "type": "array", - "items": { - "$ref": "#/components/schemas/LegacyInspection.InspectionOutput.jsonld-inspection.read" - } - } - } - } - ] + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" } } - } + }, + "links": {} }, - "403": { - "description": "Insufficient permissions" + "404": { + "description": "Not found", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} } }, - "summary": "List facility inspections", - "description": "Lists inspections directly linked to one facility.", + "summary": "Removes the Inspection resource.", + "description": "Removes the Inspection resource.", "parameters": [ { - "name": "organizationId", + "name": "id", "in": "path", - "description": "LegacyInspection identifier", + "description": "Inspection identifier", "required": true, "deprecated": false, "schema": { @@ -18876,147 +19862,465 @@ }, "style": "simple", "explode": false - }, - { - "name": "facilityId", - "in": "path", - "description": "LegacyInspection identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" + } + ] + }, + "patch": { + "operationId": "canonical_inspection_patch", + "tags": [ + "Inspection" + ], + "responses": { + "200": { + "description": "Inspection resource updated", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Inspection.InspectionOutput.jsonld" + } + } }, - "style": "simple", - "explode": false + "links": {} }, - { - "name": "page", - "in": "query", - "description": "The collection page number", - "required": false, - "deprecated": false, - "schema": { - "type": "integer", - "default": 1 + "400": { + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } }, - "style": "form", - "explode": true + "links": {} }, - { - "name": "itemsPerPage", - "in": "query", - "description": "The number of items per page", - "required": false, - "deprecated": false, - "schema": { - "type": "integer", - "default": 30, - "minimum": 0, - "maximum": 100 + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } }, - "style": "form", - "explode": true + "links": {} }, + "404": { + "description": "Not found", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + } + }, + "summary": "Updates the Inspection resource.", + "description": "Updates the Inspection resource.", + "parameters": [ { - "name": "equipmentId", - "in": "query", - "description": "Filter by equipment", - "required": false, + "name": "id", + "in": "path", + "description": "Inspection identifier", + "required": true, "deprecated": false, "schema": { "type": "string" }, - "style": "form", - "explode": true + "style": "simple", + "explode": false + } + ], + "requestBody": { + "description": "The updated Inspection resource", + "content": { + "application/merge-patch+json": { + "schema": { + "$ref": "#/components/schemas/Inspection.PatchCanonicalInspectionInput.jsonMergePatch" + } + } + }, + "required": true + } + } + }, + "/api/inspections/{inspectionId}/attachments": { + "get": { + "operationId": "list_inspection_attachments", + "tags": [ + "Inspection" + ], + "responses": { + "200": { + "description": "Attachments retrieved", + "content": { + "application/ld+json": { + "schema": { + "type": "object", + "description": "InspectionAttachment.InspectionAttachmentOutput.jsonld-inspection.read collection.", + "allOf": [ + { + "$ref": "#/components/schemas/HydraCollectionBaseSchema" + }, + { + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "array", + "items": { + "$ref": "#/components/schemas/InspectionAttachment.InspectionAttachmentOutput.jsonld-inspection.read" + } + } + } + } + ] + } + } + } + }, + "403": { + "description": "Insufficient permissions" }, + "404": { + "description": "Inspection not found" + } + }, + "summary": "List inspection attachments", + "description": "Lists the inspection-level attachments of an inspection.", + "parameters": [ { - "name": "result", - "in": "query", - "description": "Filter by result (pass, fail, partial)", - "required": false, + "name": "inspectionId", + "in": "path", + "description": "InspectionAttachment identifier", + "required": true, "deprecated": false, "schema": { "type": "string" }, - "style": "form", - "explode": true + "style": "simple", + "explode": false }, { - "name": "status", + "name": "page", "in": "query", - "description": "Filter by status (draft, submitted, closed)", + "description": "The collection page number", "required": false, "deprecated": false, "schema": { - "type": "string" + "type": "integer", + "default": 1 }, "style": "form", "explode": true + } + ] + }, + "post": { + "operationId": "add_inspection_attachment", + "tags": [ + "Inspection" + ], + "responses": { + "201": { + "description": "Attachment uploaded", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/InspectionAttachment.InspectionAttachmentOutput.jsonld-inspection.read" + } + } + }, + "links": {} + }, + "400": { + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "403": { + "description": "Insufficient permissions" + }, + "404": { + "description": "Inspection not found" }, + "422": { + "description": "MIME type or size rejected", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + } + }, + "summary": "Upload an inspection attachment", + "description": "Uploads a multipart file attachment to an inspection.", + "parameters": [ { - "name": "performedAtFrom", - "in": "query", - "description": "Filter inspections performed on or after this instant.", - "required": false, + "name": "inspectionId", + "in": "path", + "description": "InspectionAttachment identifier", + "required": true, "deprecated": false, "schema": { - "type": "string", - "format": "date-time" + "type": "string" }, - "style": "form", - "explode": true + "style": "simple", + "explode": false + } + ] + } + }, + "/api/non-conformities/{nonConformityId}/attachments": { + "get": { + "operationId": "list_non_conformity_attachments", + "tags": [ + "Inspection" + ], + "responses": { + "200": { + "description": "Attachments retrieved", + "content": { + "application/ld+json": { + "schema": { + "type": "object", + "description": "InspectionAttachment.InspectionAttachmentOutput.jsonld-inspection.read collection.", + "allOf": [ + { + "$ref": "#/components/schemas/HydraCollectionBaseSchema" + }, + { + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "array", + "items": { + "$ref": "#/components/schemas/InspectionAttachment.InspectionAttachmentOutput.jsonld-inspection.read" + } + } + } + } + ] + } + } + } }, + "403": { + "description": "Insufficient permissions" + }, + "404": { + "description": "Non-conformity not found" + } + }, + "summary": "List non-conformity field-proof photos", + "description": "Lists the field-proof photo attachments of a non-conformity.", + "parameters": [ { - "name": "performedAtTo", - "in": "query", - "description": "Filter inspections performed on or before this instant.", - "required": false, + "name": "nonConformityId", + "in": "path", + "description": "InspectionAttachment identifier", + "required": true, "deprecated": false, "schema": { - "type": "string", - "format": "date-time" + "type": "string" }, - "style": "form", - "explode": true + "style": "simple", + "explode": false }, { - "name": "inspectorUserId", + "name": "page", "in": "query", - "description": "Filter by inspector user identifier.", + "description": "The collection page number", "required": false, "deprecated": false, "schema": { - "type": "string", - "format": "uuid" + "type": "integer", + "default": 1 }, "style": "form", "explode": true + } + ] + }, + "post": { + "operationId": "add_non_conformity_attachment", + "tags": [ + "Inspection" + ], + "responses": { + "201": { + "description": "Attachment uploaded", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/InspectionAttachment.InspectionAttachmentOutput.jsonld-inspection.read" + } + } + }, + "links": {} + }, + "400": { + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "403": { + "description": "Insufficient permissions" }, + "404": { + "description": "Non-conformity not found" + }, + "422": { + "description": "MIME type or size rejected", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + } + }, + "summary": "Upload a non-conformity field-proof photo", + "description": "Uploads a multipart file attachment (field-proof photo) to a non-conformity.", + "parameters": [ { - "name": "checklistId", - "in": "query", - "description": "Filter by checklist identifier.", - "required": false, + "name": "nonConformityId", + "in": "path", + "description": "InspectionAttachment identifier", + "required": true, "deprecated": false, "schema": { - "type": "string", - "format": "uuid" + "type": "string" }, - "style": "form", - "explode": true + "style": "simple", + "explode": false } ] } }, - "/api/organizations/{organizationId}/inspections": { + "/api/organizations/{organizationId}/facilities/{facilityId}/inspections": { "get": { - "operationId": "inspection_list", + "operationId": "facility_inspection_list", "tags": [ "Inspection" ], "responses": { "200": { - "description": "Inspection list", + "description": "Facility inspection list", "content": { "application/ld+json": { "schema": { @@ -19049,8 +20353,8 @@ "description": "Insufficient permissions" } }, - "summary": "List inspections", - "description": "Lists all inspections for the organization.", + "summary": "List facility inspections", + "description": "Lists inspections directly linked to one facility.", "parameters": [ { "name": "organizationId", @@ -19064,6 +20368,18 @@ "style": "simple", "explode": false }, + { + "name": "facilityId", + "in": "path", + "description": "LegacyInspection identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, { "name": "page", "in": "query", @@ -19087,27 +20403,58 @@ "type": "integer", "default": 30, "minimum": 0, - "maximum": 200 + "maximum": 100 }, "style": "form", "explode": true }, { - "name": "equipmentId", + "name": "family", "in": "query", - "description": "Filter by equipment", + "description": "LegacyInspection family", "required": false, "deprecated": false, "schema": { - "type": "string" + "type": "string", + "enum": [ + "fire", + "safety", + "other" + ] }, "style": "form", "explode": true }, { - "name": "facilityId", + "name": "customerId", "in": "query", - "description": "Filter by facility", + "description": "LegacyInspection customerId", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "uuid" + }, + "style": "form", + "explode": true + }, + { + "name": "includeDescendants", + "in": "query", + "description": "LegacyInspection includeDescendants", + "required": false, + "deprecated": false, + "schema": { + "type": "boolean", + "default": false + }, + "style": "form", + "explode": true + }, + { + "name": "equipmentId", + "in": "query", + "description": "Filter by equipment", "required": false, "deprecated": false, "schema": { @@ -19193,13 +20540,243 @@ "explode": true } ] - }, - "post": { - "operationId": "inspection_create", - "tags": [ - "Inspection" - ], - "responses": { + } + }, + "/api/organizations/{organizationId}/inspections": { + "get": { + "operationId": "inspection_list", + "tags": [ + "Inspection" + ], + "responses": { + "200": { + "description": "Inspection list", + "content": { + "application/ld+json": { + "schema": { + "type": "object", + "description": "LegacyInspection.InspectionOutput.jsonld-inspection.read collection.", + "allOf": [ + { + "$ref": "#/components/schemas/HydraCollectionBaseSchema" + }, + { + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LegacyInspection.InspectionOutput.jsonld-inspection.read" + } + } + } + } + ] + } + } + } + }, + "403": { + "description": "Insufficient permissions" + } + }, + "summary": "List inspections", + "description": "Lists all inspections for the organization.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "LegacyInspection identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "page", + "in": "query", + "description": "The collection page number", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 1 + }, + "style": "form", + "explode": true + }, + { + "name": "itemsPerPage", + "in": "query", + "description": "The number of items per page", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 30, + "minimum": 0, + "maximum": 200 + }, + "style": "form", + "explode": true + }, + { + "name": "family", + "in": "query", + "description": "LegacyInspection family", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "enum": [ + "fire", + "safety", + "other" + ] + }, + "style": "form", + "explode": true + }, + { + "name": "customerId", + "in": "query", + "description": "LegacyInspection customerId", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "uuid" + }, + "style": "form", + "explode": true + }, + { + "name": "includeDescendants", + "in": "query", + "description": "LegacyInspection includeDescendants", + "required": false, + "deprecated": false, + "schema": { + "type": "boolean", + "default": false + }, + "style": "form", + "explode": true + }, + { + "name": "equipmentId", + "in": "query", + "description": "Filter by equipment", + "required": false, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "form", + "explode": true + }, + { + "name": "facilityId", + "in": "query", + "description": "Filter by facility", + "required": false, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "form", + "explode": true + }, + { + "name": "result", + "in": "query", + "description": "Filter by result (pass, fail, partial)", + "required": false, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "form", + "explode": true + }, + { + "name": "status", + "in": "query", + "description": "Filter by status (draft, submitted, closed)", + "required": false, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "form", + "explode": true + }, + { + "name": "performedAtFrom", + "in": "query", + "description": "Filter inspections performed on or after this instant.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "date-time" + }, + "style": "form", + "explode": true + }, + { + "name": "performedAtTo", + "in": "query", + "description": "Filter inspections performed on or before this instant.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "date-time" + }, + "style": "form", + "explode": true + }, + { + "name": "inspectorUserId", + "in": "query", + "description": "Filter by inspector user identifier.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "uuid" + }, + "style": "form", + "explode": true + }, + { + "name": "checklistId", + "in": "query", + "description": "Filter by checklist identifier.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "uuid" + }, + "style": "form", + "explode": true + } + ] + }, + "post": { + "operationId": "inspection_create", + "tags": [ + "Inspection" + ], + "responses": { "201": { "description": "Inspection created", "content": { @@ -20662,20 +22239,20 @@ ] } }, - "/api/inspection-responses": { + "/api/organizations/{organizationId}/park-anomalies": { "get": { - "operationId": "api_inspection-responses_get_collection", + "operationId": "park_anomalies_list", "tags": [ - "InspectionResponse" + "Inspection" ], "responses": { "200": { - "description": "InspectionResponse collection", + "description": "Scoped paginated anomaly queue", "content": { "application/ld+json": { "schema": { "type": "object", - "description": "InspectionResponse.InspectionResponseOutput.jsonld collection.", + "description": "ParkAnomaly.NonConformityOutput.jsonld-inspection.read collection.", "allOf": [ { "$ref": "#/components/schemas/HydraCollectionBaseSchema" @@ -20689,7 +22266,7 @@ "member": { "type": "array", "items": { - "$ref": "#/components/schemas/InspectionResponse.InspectionResponseOutput.jsonld" + "$ref": "#/components/schemas/ParkAnomaly.NonConformityOutput.jsonld-inspection.read" } } } @@ -20699,31 +22276,31 @@ } } }, + "400": { + "description": "Invalid family or scope input" + }, "403": { - "description": "Forbidden", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "links": {} + "description": "Missing equipment or inspection read" + }, + "404": { + "description": "Unknown or hidden scope" } }, - "summary": "Retrieves the collection of InspectionResponse resources.", - "description": "Retrieves the collection of InspectionResponse resources.", + "summary": "List unresolved published parc anomalies", + "description": "List unresolved published parc anomalies", "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "ParkAnomaly identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, { "name": "page", "in": "query", @@ -20745,7 +22322,7 @@ "deprecated": false, "schema": { "type": "integer", - "default": 50, + "default": 30, "minimum": 0, "maximum": 100 }, @@ -20753,96 +22330,346 @@ "explode": true }, { - "name": "organization", + "name": "family", "in": "query", - "description": "Organization IRI. Required when intervention and inspection are omitted.", + "description": "ParkAnomaly family", "required": false, "deprecated": false, "schema": { - "type": "string" + "type": "string", + "enum": [ + "fire", + "safety", + "other" + ] }, "style": "form", "explode": true }, { - "name": "intervention", + "name": "customerId", "in": "query", - "description": "Intervention IRI. Also scopes the organization and defaults recordStatus to draft.", + "description": "ParkAnomaly customerId", "required": false, "deprecated": false, "schema": { - "type": "string" + "type": "string", + "format": "uuid" }, "style": "form", "explode": true }, { - "name": "inspection", + "name": "facilityId", "in": "query", - "description": "Inspection IRI. Also scopes the organization.", + "description": "ParkAnomaly facilityId", "required": false, "deprecated": false, "schema": { - "type": "string" + "type": "string", + "format": "uuid" }, "style": "form", "explode": true }, { - "name": "recordStatus", + "name": "includeDescendants", "in": "query", - "description": "Lifecycle status of the representation.", + "description": "ParkAnomaly includeDescendants", "required": false, "deprecated": false, "schema": { - "type": "string", - "enum": [ - "draft", - "published" - ] + "type": "boolean", + "default": true }, "style": "form", "explode": true } ] - }, - "post": { - "operationId": "api_inspection-responses_post", + } + }, + "/api/organizations/{organizationId}/park-anomalies-summary": { + "get": { + "operationId": "park_anomalies_summary_get", "tags": [ - "InspectionResponse" + "Inspection" ], "responses": { - "201": { - "description": "InspectionResponse resource created", + "200": { + "description": "Counts by severity", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/InspectionResponse.InspectionResponseOutput.jsonld" + "$ref": "#/components/schemas/ParkAnomaly.ParkAnomaliesSummaryOutput.jsonld-inspection.read" } } - }, - "links": {} + } }, "400": { - "description": "Invalid input", + "description": "Invalid family or scope input" + }, + "403": { + "description": "Missing equipment or inspection read" + }, + "404": { + "description": "Unknown or hidden scope" + } + }, + "summary": "Count unresolved published parc anomalies", + "description": "Count unresolved published parc anomalies", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "ParkAnomaly identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "family", + "in": "query", + "description": "ParkAnomaly family", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "enum": [ + "fire", + "safety", + "other" + ] + }, + "style": "form", + "explode": true + }, + { + "name": "customerId", + "in": "query", + "description": "ParkAnomaly customerId", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "uuid" + }, + "style": "form", + "explode": true + }, + { + "name": "facilityId", + "in": "query", + "description": "ParkAnomaly facilityId", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "uuid" + }, + "style": "form", + "explode": true + }, + { + "name": "includeDescendants", + "in": "query", + "description": "ParkAnomaly includeDescendants", + "required": false, + "deprecated": false, + "schema": { + "type": "boolean", + "default": true + }, + "style": "form", + "explode": true + } + ] + } + }, + "/api/inspection-responses": { + "get": { + "operationId": "api_inspection-responses_get_collection", + "tags": [ + "InspectionResponse" + ], + "responses": { + "200": { + "description": "InspectionResponse collection", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" + "type": "object", + "description": "InspectionResponse.InspectionResponseOutput.jsonld collection.", + "allOf": [ + { + "$ref": "#/components/schemas/HydraCollectionBaseSchema" + }, + { + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "array", + "items": { + "$ref": "#/components/schemas/InspectionResponse.InspectionResponseOutput.jsonld" + } + } + } + } + ] } } - }, - "links": {} + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + } + }, + "summary": "Retrieves the collection of InspectionResponse resources.", + "description": "Retrieves the collection of InspectionResponse resources.", + "parameters": [ + { + "name": "page", + "in": "query", + "description": "The collection page number", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 1 + }, + "style": "form", + "explode": true + }, + { + "name": "itemsPerPage", + "in": "query", + "description": "The number of items per page", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 50, + "minimum": 0, + "maximum": 100 + }, + "style": "form", + "explode": true + }, + { + "name": "organization", + "in": "query", + "description": "Organization IRI. Required when intervention and inspection are omitted.", + "required": false, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "form", + "explode": true + }, + { + "name": "intervention", + "in": "query", + "description": "Intervention IRI. Also scopes the organization and defaults recordStatus to draft.", + "required": false, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "form", + "explode": true + }, + { + "name": "inspection", + "in": "query", + "description": "Inspection IRI. Also scopes the organization.", + "required": false, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "form", + "explode": true + }, + { + "name": "recordStatus", + "in": "query", + "description": "Lifecycle status of the representation.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "enum": [ + "draft", + "published" + ] + }, + "style": "form", + "explode": true + } + ] + }, + "post": { + "operationId": "api_inspection-responses_post", + "tags": [ + "InspectionResponse" + ], + "responses": { + "201": { + "description": "InspectionResponse resource created", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/InspectionResponse.InspectionResponseOutput.jsonld" + } + } + }, + "links": {} + }, + "400": { + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} }, "403": { "description": "Forbidden", @@ -25265,6 +27092,48 @@ }, "style": "simple", "explode": false + }, + { + "name": "page", + "in": "query", + "description": "InterventionTime page", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "minimum": 1, + "default": 1 + }, + "style": "form", + "explode": true + }, + { + "name": "itemsPerPage", + "in": "query", + "description": "InterventionTime itemsPerPage", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 30 + }, + "style": "form", + "explode": true + }, + { + "name": "ownOnly", + "in": "query", + "description": "Limit entries and their total to the current member, including for time managers.", + "required": false, + "deprecated": false, + "schema": { + "type": "boolean", + "default": false + }, + "style": "form", + "explode": true } ] }, @@ -25379,14 +27248,21 @@ } }, "/api/intervention-work-items/{taskId}/time-entries/{entryId}": { - "delete": { - "operationId": "intervention_time_cancel", + "get": { + "operationId": "intervention_time_get", "tags": [ "InterventionTime" ], "responses": { - "204": { - "description": "InterventionTime resource deleted" + "200": { + "description": "InterventionTime resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/InterventionTime.TimeEntryOutput.jsonld" + } + } + } }, "403": { "description": "Forbidden", @@ -25431,8 +27307,8 @@ "links": {} } }, - "summary": "Removes the InterventionTime resource.", - "description": "Removes the InterventionTime resource.", + "summary": "Retrieves a InterventionTime resource.", + "description": "Retrieves a InterventionTime resource.", "parameters": [ { "name": "taskId", @@ -25460,43 +27336,14 @@ } ] }, - "patch": { - "operationId": "intervention_time_correct", + "delete": { + "operationId": "intervention_time_cancel", "tags": [ "InterventionTime" ], "responses": { - "200": { - "description": "InterventionTime resource updated", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/InterventionTime.TimeEntryOutput.jsonld" - } - } - }, - "links": {} - }, - "400": { - "description": "Invalid input", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "links": {} + "204": { + "description": "InterventionTime resource deleted" }, "403": { "description": "Forbidden", @@ -25539,31 +27386,141 @@ } }, "links": {} - }, - "422": { - "description": "An error occurred", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" - } - } - }, - "links": {} } }, - "summary": "Updates the InterventionTime resource.", - "description": "Updates the InterventionTime resource.", + "summary": "Removes the InterventionTime resource.", + "description": "Removes the InterventionTime resource.", + "parameters": [ + { + "name": "taskId", + "in": "path", + "description": "InterventionTime identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "entryId", + "in": "path", + "description": "InterventionTime identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ] + }, + "patch": { + "operationId": "intervention_time_correct", + "tags": [ + "InterventionTime" + ], + "responses": { + "200": { + "description": "InterventionTime resource updated", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/InterventionTime.TimeEntryOutput.jsonld" + } + } + }, + "links": {} + }, + "400": { + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "404": { + "description": "Not found", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + } + }, + "summary": "Updates the InterventionTime resource.", + "description": "Updates the InterventionTime resource.", "parameters": [ { "name": "taskId", @@ -25603,6 +27560,124 @@ } } }, + "/api/intervention-work-items/{taskId}/time-entries/{entryId}/versions": { + "get": { + "operationId": "intervention_time_versions", + "tags": [ + "InterventionTime" + ], + "responses": { + "200": { + "description": "InterventionTime resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/InterventionTime.TimeEntryHistoryOutput.jsonld" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "404": { + "description": "Not found", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + } + }, + "summary": "Retrieves a InterventionTime resource.", + "description": "Retrieves a InterventionTime resource.", + "parameters": [ + { + "name": "taskId", + "in": "path", + "description": "InterventionTime identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "entryId", + "in": "path", + "description": "InterventionTime identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "beforeRevision", + "in": "query", + "description": "Exclusive revision cursor; omitted for the newest retained revisions.", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "minimum": 1 + }, + "style": "form", + "explode": true + }, + { + "name": "itemsPerPage", + "in": "query", + "description": "InterventionTime itemsPerPage", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 30 + }, + "style": "form", + "explode": true + } + ] + } + }, "/api/intervention-work-items": { "get": { "operationId": "intervention_work_item_list", @@ -26400,75 +28475,112 @@ } } }, - "/api/maintenance/schedules/export": { + "/api/organizations/{organizationId}/inventory-balances": { "get": { - "operationId": "maintenance_schedule_export", + "operationId": "inventory_balances_list", "tags": [ - "Maintenance" + "Inventory" ], "responses": { "200": { - "description": "CSV export streamed successfully" - }, - "204": { - "description": "MaintenanceSchedule resource" - }, - "400": { - "description": "Missing organization filter" + "description": "InventoryBalance collection", + "content": { + "application/ld+json": { + "schema": { + "type": "object", + "description": "InventoryBalance.InventoryBalanceOutput.jsonld-inventory.read collection.", + "allOf": [ + { + "$ref": "#/components/schemas/HydraCollectionBaseSchema" + }, + { + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "array", + "items": { + "$ref": "#/components/schemas/InventoryBalance.InventoryBalanceOutput.jsonld-inventory.read" + } + } + } + } + ] + } + } + } }, "403": { - "description": "Authenticated but missing organization.maintenance.read" - }, - "404": { - "description": "The organization is outside the caller's scope" - }, - "422": { - "description": "Export exceeds the row cap; narrow the filters and retry" + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} } }, - "summary": "Export maintenance schedules (CSV)", - "description": "Streams a CSV export of maintenance schedules (Content-Disposition: attachment) for the given organization, using the same filters as the list endpoint (`facility`, `equipmentType`, `dueStatus`, `dueBefore`). Requires `organization.maintenance.read` on the organization, resolved the same way the list endpoint resolves it — a resource-level ROLE_USER check alone does not grant access. Bounded to 50000 matching rows — the request is rejected with 422 if the filters match more; narrow with a more specific filter and retry.", + "summary": "Read nonnegative stock balances", + "description": "Read nonnegative stock balances", "parameters": [ { - "name": "organization", - "in": "query", - "description": "Organization IRI.", + "name": "organizationId", + "in": "path", + "description": "InventoryBalance identifier", "required": true, "deprecated": false, "schema": { "type": "string" }, - "style": "form", - "explode": true + "style": "simple", + "explode": false }, { - "name": "facility", + "name": "page", "in": "query", - "description": "Facility IRI filter.", + "description": "The collection page number", "required": false, "deprecated": false, "schema": { - "type": "string" + "type": "integer", + "default": 1 }, "style": "form", "explode": true }, { - "name": "equipmentType", + "name": "itemsPerPage", "in": "query", - "description": "Equipment type filter.", + "description": "The number of items per page", "required": false, "deprecated": false, "schema": { - "type": "string" + "type": "integer", + "default": 30, + "minimum": 0, + "maximum": 30 }, "style": "form", "explode": true }, { - "name": "dueStatus", + "name": "warehouseId", "in": "query", - "description": "Due status filter (unscheduled|up_to_date|due_soon|overdue).", + "description": "InventoryBalance warehouseId", "required": false, "deprecated": false, "schema": { @@ -26478,9 +28590,9 @@ "explode": true }, { - "name": "dueBefore", + "name": "partId", "in": "query", - "description": "ISO-8601 upper bound on the next due date.", + "description": "InventoryBalance partId", "required": false, "deprecated": false, "schema": { @@ -26489,126 +28601,23 @@ "style": "form", "explode": true } - ], - "security": [ - { - "bearerAuth": [] - } ] } }, - "/api/maintenance/campaigns": { - "post": { - "operationId": "api_maintenancecampaigns_post", - "tags": [ - "MaintenanceCampaign" - ], - "responses": { - "201": { - "description": "MaintenanceCampaign resource created", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/MaintenanceCampaign.GenerateInspectionCampaignOutput.jsonld" - } - } - }, - "links": {} - }, - "400": { - "description": "Invalid input", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "links": {} - }, - "403": { - "description": "Forbidden", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "links": {} - }, - "422": { - "description": "An error occurred", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" - } - } - }, - "links": {} - } - }, - "summary": "Creates a MaintenanceCampaign resource.", - "description": "Creates a MaintenanceCampaign resource.", - "parameters": [], - "requestBody": { - "description": "The new MaintenanceCampaign resource", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/MaintenanceCampaign.GenerateInspectionCampaignInput" - } - } - }, - "required": true - } - } - }, - "/api/maintenance/schedules": { + "/api/organizations/{organizationId}/inventory-consumptions": { "get": { - "operationId": "api_maintenanceschedules_get_collection", + "operationId": "inventory_consumptions_list", "tags": [ - "MaintenanceSchedule" + "Inventory" ], "responses": { "200": { - "description": "MaintenanceSchedule collection", + "description": "InventoryConsumption collection", "content": { "application/ld+json": { "schema": { "type": "object", - "description": "MaintenanceSchedule.MaintenanceScheduleOutput.jsonld collection.", + "description": "InventoryConsumption.InventoryConsumptionOutput.jsonld-inventory.read collection.", "allOf": [ { "$ref": "#/components/schemas/HydraCollectionBaseSchema" @@ -26622,7 +28631,7 @@ "member": { "type": "array", "items": { - "$ref": "#/components/schemas/MaintenanceSchedule.MaintenanceScheduleOutput.jsonld" + "$ref": "#/components/schemas/InventoryConsumption.InventoryConsumptionOutput.jsonld-inventory.read" } } } @@ -26654,9 +28663,21 @@ "links": {} } }, - "summary": "Retrieves the collection of MaintenanceSchedule resources.", - "description": "Retrieves the collection of MaintenanceSchedule resources.", + "summary": "Declare complete physical consumptions, retaining shortages", + "description": "Declare complete physical consumptions, retaining shortages", "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "InventoryConsumption identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, { "name": "page", "in": "query", @@ -26680,27 +28701,15 @@ "type": "integer", "default": 30, "minimum": 0, - "maximum": 100 - }, - "style": "form", - "explode": true - }, - { - "name": "organization", - "in": "query", - "description": "Organization IRI.", - "required": true, - "deprecated": false, - "schema": { - "type": "string" + "maximum": 30 }, "style": "form", "explode": true }, { - "name": "facility", + "name": "warehouseId", "in": "query", - "description": "Facility IRI filter.", + "description": "InventoryConsumption warehouseId", "required": false, "deprecated": false, "schema": { @@ -26710,9 +28719,9 @@ "explode": true }, { - "name": "equipmentType", + "name": "partId", "in": "query", - "description": "Equipment type filter.", + "description": "InventoryConsumption partId", "required": false, "deprecated": false, "schema": { @@ -26722,9 +28731,9 @@ "explode": true }, { - "name": "dueStatus", + "name": "interventionId", "in": "query", - "description": "Due status filter (unscheduled|up_to_date|due_soon|overdue).", + "description": "InventoryConsumption interventionId", "required": false, "deprecated": false, "schema": { @@ -26734,9 +28743,9 @@ "explode": true }, { - "name": "dueBefore", + "name": "status", "in": "query", - "description": "ISO-8601 upper bound on the next due date.", + "description": "InventoryConsumption status", "required": false, "deprecated": false, "schema": { @@ -26746,97 +28755,19 @@ "explode": true } ] - } - }, - "/api/maintenance/schedules/{id}": { - "get": { - "operationId": "api_maintenanceschedules_id_get", - "tags": [ - "MaintenanceSchedule" - ], - "responses": { - "200": { - "description": "MaintenanceSchedule resource", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/MaintenanceSchedule.MaintenanceScheduleOutput.jsonld" - } - } - } - }, - "403": { - "description": "Forbidden", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "links": {} - }, - "404": { - "description": "Not found", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "links": {} - } - }, - "summary": "Retrieves a MaintenanceSchedule resource.", - "description": "Retrieves a MaintenanceSchedule resource.", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "MaintenanceSchedule identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - } - ] }, - "patch": { - "operationId": "api_maintenanceschedules_id_patch", + "post": { + "operationId": "inventory_consumptions_create", "tags": [ - "MaintenanceSchedule" + "InventoryConsumption" ], "responses": { - "200": { - "description": "MaintenanceSchedule resource updated", + "201": { + "description": "InventoryConsumption resource created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/MaintenanceSchedule.MaintenanceScheduleOutput.jsonld" + "$ref": "#/components/schemas/InventoryConsumption.InventoryConsumptionOutput.jsonld-inventory.read" } } }, @@ -26884,27 +28815,6 @@ }, "links": {} }, - "404": { - "description": "Not found", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "links": {} - }, "422": { "description": "An error occurred", "content": { @@ -26927,13 +28837,13 @@ "links": {} } }, - "summary": "Updates the MaintenanceSchedule resource.", - "description": "Updates the MaintenanceSchedule resource.", + "summary": "Creates a InventoryConsumption resource.", + "description": "Creates a InventoryConsumption resource.", "parameters": [ { - "name": "id", + "name": "organizationId", "in": "path", - "description": "MaintenanceSchedule identifier", + "description": "InventoryConsumption identifier", "required": true, "deprecated": false, "schema": { @@ -26944,11 +28854,11 @@ } ], "requestBody": { - "description": "The updated MaintenanceSchedule resource", + "description": "The new InventoryConsumption resource", "content": { - "application/merge-patch+json": { + "application/ld+json": { "schema": { - "$ref": "#/components/schemas/MaintenanceSchedule.UpdateMaintenanceScheduleInput.jsonMergePatch" + "$ref": "#/components/schemas/InventoryConsumption.DeclareInventoryConsumptionInput-inventory.write" } } }, @@ -26956,44 +28866,42 @@ } } }, - "/api/media": { - "post": { - "operationId": "api_media_post", + "/api/organizations/{organizationId}/inventory-movements": { + "get": { + "operationId": "inventory_movements_list", "tags": [ - "Media" + "Inventory" ], "responses": { - "201": { - "description": "Media resource created", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Media.AttachmentOutput.jsonld" - } - } - }, - "links": {} - }, - "400": { - "description": "Invalid input", + "200": { + "description": "InventoryMovement collection", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" + "type": "object", + "description": "InventoryMovement.InventoryMovementOutput.jsonld-inventory.read collection.", + "allOf": [ + { + "$ref": "#/components/schemas/HydraCollectionBaseSchema" + }, + { + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "array", + "items": { + "$ref": "#/components/schemas/InventoryMovement.InventoryMovementOutput.jsonld-inventory.read" + } + } + } + } + ] } } - }, - "links": {} + } }, "403": { "description": "Forbidden", @@ -27015,47 +28923,123 @@ } }, "links": {} + } + }, + "summary": "Read immutable motivated stock movements", + "description": "Read immutable motivated stock movements", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "InventoryMovement identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false }, - "422": { - "description": "An error occurred", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" - } - } + { + "name": "page", + "in": "query", + "description": "The collection page number", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 1 }, - "links": {} + "style": "form", + "explode": true + }, + { + "name": "itemsPerPage", + "in": "query", + "description": "The number of items per page", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 30, + "minimum": 0, + "maximum": 30 + }, + "style": "form", + "explode": true + }, + { + "name": "warehouseId", + "in": "query", + "description": "InventoryMovement warehouseId", + "required": false, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "form", + "explode": true + }, + { + "name": "partId", + "in": "query", + "description": "InventoryMovement partId", + "required": false, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "form", + "explode": true + }, + { + "name": "interventionId", + "in": "query", + "description": "InventoryMovement interventionId", + "required": false, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "form", + "explode": true } - }, - "summary": "Creates a Media resource.", - "description": "Creates a Media resource.", - "parameters": [] + ] } }, - "/api/media/{id}": { + "/api/organizations/{organizationId}/inventory-parts": { "get": { - "operationId": "api_media_id_get", + "operationId": "inventory_parts_list", "tags": [ - "Media" + "Inventory" ], "responses": { "200": { - "description": "Media resource", + "description": "InventoryPart collection", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Media.AttachmentOutput.jsonld" + "type": "object", + "description": "InventoryPart.InventoryPartOutput.jsonld-inventory.read collection.", + "allOf": [ + { + "$ref": "#/components/schemas/HydraCollectionBaseSchema" + }, + { + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "array", + "items": { + "$ref": "#/components/schemas/InventoryPart.InventoryPartOutput.jsonld-inventory.read" + } + } + } + } + ] } } } @@ -27080,36 +29064,15 @@ } }, "links": {} - }, - "404": { - "description": "Not found", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "links": {} } }, - "summary": "Retrieves a Media resource.", - "description": "Retrieves a Media resource.", + "summary": "Manage quantitative part and consumable references", + "description": "Manage quantitative part and consumable references", "parameters": [ { - "name": "id", + "name": "organizationId", "in": "path", - "description": "Media identifier", + "description": "InventoryPart identifier", "required": true, "deprecated": false, "schema": { @@ -27117,20 +29080,84 @@ }, "style": "simple", "explode": false + }, + { + "name": "page", + "in": "query", + "description": "The collection page number", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 1 + }, + "style": "form", + "explode": true + }, + { + "name": "itemsPerPage", + "in": "query", + "description": "The number of items per page", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 30, + "minimum": 0, + "maximum": 30 + }, + "style": "form", + "explode": true + }, + { + "name": "search", + "in": "query", + "description": "InventoryPart search", + "required": false, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "form", + "explode": true + }, + { + "name": "archived", + "in": "query", + "description": "InventoryPart archived", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "enum": [ + "true", + "false" + ] + }, + "style": "form", + "explode": true } ] }, - "delete": { - "operationId": "api_media_id_delete", + "post": { + "operationId": "inventory_parts_create", "tags": [ - "Media" + "InventoryPart" ], "responses": { - "204": { - "description": "Media resource deleted" + "201": { + "description": "InventoryPart resource created", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/InventoryPart.InventoryPartOutput.jsonld-inventory.read" + } + } + }, + "links": {} }, - "403": { - "description": "Forbidden", + "400": { + "description": "Invalid input", "content": { "application/ld+json": { "schema": { @@ -27150,8 +29177,8 @@ }, "links": {} }, - "404": { - "description": "Not found", + "403": { + "description": "Forbidden", "content": { "application/ld+json": { "schema": { @@ -27170,15 +29197,36 @@ } }, "links": {} + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} } }, - "summary": "Removes the Media resource.", - "description": "Removes the Media resource.", + "summary": "Creates a InventoryPart resource.", + "description": "Creates a InventoryPart resource.", "parameters": [ { - "name": "id", + "name": "organizationId", "in": "path", - "description": "Media identifier", + "description": "InventoryPart identifier", "required": true, "deprecated": false, "schema": { @@ -27187,23 +29235,34 @@ "style": "simple", "explode": false } - ] + ], + "requestBody": { + "description": "The new InventoryPart resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/InventoryPart.CreateInventoryPartInput-inventory.write" + } + } + }, + "required": true + } } }, - "/api/conversations/{conversationId}/messages": { + "/api/organizations/{organizationId}/inventory-warehouses": { "get": { - "operationId": "api_conversations_conversationIdmessages_get_collection", + "operationId": "inventory_warehouses_list", "tags": [ - "Message" + "Inventory" ], "responses": { "200": { - "description": "Message collection", + "description": "InventoryWarehouse collection", "content": { "application/ld+json": { "schema": { "type": "object", - "description": "Message.MessageOutput.jsonld collection.", + "description": "InventoryWarehouse.InventoryWarehouseOutput.jsonld-inventory.read collection.", "allOf": [ { "$ref": "#/components/schemas/HydraCollectionBaseSchema" @@ -27217,7 +29276,7 @@ "member": { "type": "array", "items": { - "$ref": "#/components/schemas/Message.MessageOutput.jsonld" + "$ref": "#/components/schemas/InventoryWarehouse.InventoryWarehouseOutput.jsonld-inventory.read" } } } @@ -27249,13 +29308,13 @@ "links": {} } }, - "summary": "Retrieves the collection of Message resources.", - "description": "Retrieves the collection of Message resources.", + "summary": "Manage warehouses", + "description": "Manage warehouses", "parameters": [ { - "name": "conversationId", + "name": "organizationId", "in": "path", - "description": "Message identifier", + "description": "InventoryWarehouse identifier", "required": true, "deprecated": false, "schema": { @@ -27287,7 +29346,35 @@ "type": "integer", "default": 30, "minimum": 0, - "maximum": 100 + "maximum": 30 + }, + "style": "form", + "explode": true + }, + { + "name": "search", + "in": "query", + "description": "InventoryWarehouse search", + "required": false, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "form", + "explode": true + }, + { + "name": "archived", + "in": "query", + "description": "InventoryWarehouse archived", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "enum": [ + "true", + "false" + ] }, "style": "form", "explode": true @@ -27295,17 +29382,17 @@ ] }, "post": { - "operationId": "api_conversations_conversationIdmessages_post", + "operationId": "inventory_warehouses_create", "tags": [ - "Message" + "InventoryWarehouse" ], "responses": { "201": { - "description": "Message resource created", + "description": "InventoryWarehouse resource created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Message.MessageOutput.jsonld" + "$ref": "#/components/schemas/InventoryWarehouse.InventoryWarehouseOutput.jsonld-inventory.read" } } }, @@ -27375,13 +29462,13 @@ "links": {} } }, - "summary": "Creates a Message resource.", - "description": "Creates a Message resource.", + "summary": "Creates a InventoryWarehouse resource.", + "description": "Creates a InventoryWarehouse resource.", "parameters": [ { - "name": "conversationId", + "name": "organizationId", "in": "path", - "description": "Message identifier", + "description": "InventoryWarehouse identifier", "required": true, "deprecated": false, "schema": { @@ -27392,11 +29479,11 @@ } ], "requestBody": { - "description": "The new Message resource", + "description": "The new InventoryWarehouse resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Message.PostMessageInput" + "$ref": "#/components/schemas/InventoryWarehouse.CreateInventoryWarehouseInput-inventory.write" } } }, @@ -27404,26 +29491,25 @@ } } }, - "/api/conversations/{conversationId}/messages/{clientId}": { - "put": { - "operationId": "put_message", + "/api/organizations/{organizationId}/inventory-consumptions/{id}": { + "get": { + "operationId": "inventory_consumptions_get", "tags": [ - "Message" + "InventoryConsumption" ], "responses": { - "201": { - "description": "Message created", + "200": { + "description": "InventoryConsumption resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Message.MessageOutput.jsonld" + "$ref": "#/components/schemas/InventoryConsumption.InventoryConsumptionOutput.jsonld-inventory.read" } } - }, - "links": {} + } }, - "400": { - "description": "Invalid input", + "403": { + "description": "Forbidden", "content": { "application/ld+json": { "schema": { @@ -27443,8 +29529,8 @@ }, "links": {} }, - "403": { - "description": "Forbidden", + "404": { + "description": "Not found", "content": { "application/ld+json": { "schema": { @@ -27463,9 +29549,58 @@ } }, "links": {} + } + }, + "summary": "Retrieves a InventoryConsumption resource.", + "description": "Retrieves a InventoryConsumption resource.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "InventoryConsumption identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false }, - "404": { - "description": "Not found", + { + "name": "id", + "in": "path", + "description": "InventoryConsumption identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ] + } + }, + "/api/organizations/{organizationId}/inventory-consumptions/{id}/reconcile": { + "post": { + "operationId": "inventory_consumptions_reconcile", + "tags": [ + "InventoryConsumption" + ], + "responses": { + "200": { + "description": "InventoryConsumption resource created", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/InventoryConsumption.InventoryConsumptionOutput.jsonld-inventory.read" + } + } + }, + "links": {} + }, + "400": { + "description": "Invalid input", "content": { "application/ld+json": { "schema": { @@ -27485,11 +29620,26 @@ }, "links": {} }, - "409": { - "description": "This client identifier was already used" - }, - "412": { - "description": "If-None-Match must be *" + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} }, "422": { "description": "An error occurred", @@ -27511,18 +29661,15 @@ } }, "links": {} - }, - "428": { - "description": "If-None-Match: * is required" } }, - "summary": "Replaces the Message resource.", - "description": "Replaces the Message resource.", + "summary": "Creates a InventoryConsumption resource.", + "description": "Creates a InventoryConsumption resource.", "parameters": [ { - "name": "conversationId", + "name": "organizationId", "in": "path", - "description": "Message identifier", + "description": "InventoryConsumption identifier", "required": true, "deprecated": false, "schema": { @@ -27532,9 +29679,9 @@ "explode": false }, { - "name": "clientId", + "name": "id", "in": "path", - "description": "Message identifier", + "description": "InventoryConsumption identifier", "required": true, "deprecated": false, "schema": { @@ -27545,11 +29692,11 @@ } ], "requestBody": { - "description": "The updated Message resource", + "description": "The new InventoryConsumption resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Message.PostMessageInput" + "$ref": "#/components/schemas/InventoryConsumption.ReconcileInventoryConsumptionInput-inventory.write" } } }, @@ -27557,39 +29704,19 @@ } } }, - "/api/conversations/{conversationId}/pinned-messages": { + "/api/organizations/{organizationId}/inventory-movements/{id}": { "get": { - "operationId": "list_pinned_messages", + "operationId": "inventory_movements_get", "tags": [ - "Message" + "InventoryMovement" ], "responses": { "200": { - "description": "Message collection", + "description": "InventoryMovement resource", "content": { "application/ld+json": { "schema": { - "type": "object", - "description": "Message.MessageOutput.jsonld collection.", - "allOf": [ - { - "$ref": "#/components/schemas/HydraCollectionBaseSchema" - }, - { - "type": "object", - "required": [ - "member" - ], - "properties": { - "member": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Message.MessageOutput.jsonld" - } - } - } - } - ] + "$ref": "#/components/schemas/InventoryMovement.InventoryMovementOutput.jsonld-inventory.read" } } } @@ -27614,15 +29741,36 @@ } }, "links": {} + }, + "404": { + "description": "Not found", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} } }, - "summary": "Retrieves the collection of Message resources.", - "description": "Retrieves the collection of Message resources.", + "summary": "Retrieves a InventoryMovement resource.", + "description": "Retrieves a InventoryMovement resource.", "parameters": [ { - "name": "conversationId", + "name": "organizationId", "in": "path", - "description": "Message identifier", + "description": "InventoryMovement identifier", "required": true, "deprecated": false, "schema": { @@ -27632,45 +29780,36 @@ "explode": false }, { - "name": "page", - "in": "query", - "description": "The collection page number", - "required": false, - "deprecated": false, - "schema": { - "type": "integer", - "default": 1 - }, - "style": "form", - "explode": true - }, - { - "name": "itemsPerPage", - "in": "query", - "description": "The number of items per page", - "required": false, + "name": "id", + "in": "path", + "description": "InventoryMovement identifier", + "required": true, "deprecated": false, "schema": { - "type": "integer", - "default": 30, - "minimum": 0, - "maximum": 100 + "type": "string" }, - "style": "form", - "explode": true + "style": "simple", + "explode": false } ] } }, - "/api/messages/{id}": { - "delete": { - "operationId": "api_messages_id_delete", + "/api/organizations/{organizationId}/inventory-parts/{id}": { + "get": { + "operationId": "inventory_parts_get", "tags": [ - "Message" + "InventoryPart" ], "responses": { - "204": { - "description": "Message resource deleted" + "200": { + "description": "InventoryPart resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/InventoryPart.InventoryPartOutput.jsonld-inventory.read" + } + } + } }, "403": { "description": "Forbidden", @@ -27715,13 +29854,25 @@ "links": {} } }, - "summary": "Removes the Message resource.", - "description": "Removes the Message resource.", + "summary": "Retrieves a InventoryPart resource.", + "description": "Retrieves a InventoryPart resource.", "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "InventoryPart identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, { "name": "id", "in": "path", - "description": "Message identifier", + "description": "InventoryPart identifier", "required": true, "deprecated": false, "schema": { @@ -27733,17 +29884,17 @@ ] }, "patch": { - "operationId": "api_messages_id_patch", + "operationId": "inventory_parts_patch", "tags": [ - "Message" + "InventoryPart" ], "responses": { "200": { - "description": "Message resource updated", + "description": "InventoryPart resource updated", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Message.MessageOutput.jsonld" + "$ref": "#/components/schemas/InventoryPart.InventoryPartOutput.jsonld-inventory.read" } } }, @@ -27834,13 +29985,25 @@ "links": {} } }, - "summary": "Updates the Message resource.", - "description": "Updates the Message resource.", + "summary": "Updates the InventoryPart resource.", + "description": "Updates the InventoryPart resource.", "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "InventoryPart identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, { "name": "id", "in": "path", - "description": "Message identifier", + "description": "InventoryPart identifier", "required": true, "deprecated": false, "schema": { @@ -27851,11 +30014,11 @@ } ], "requestBody": { - "description": "The updated Message resource", + "description": "The updated InventoryPart resource", "content": { "application/merge-patch+json": { "schema": { - "$ref": "#/components/schemas/Message.EditMessageInput.jsonMergePatch" + "$ref": "#/components/schemas/InventoryPart.PatchInventoryPartInput-inventory.write.jsonMergePatch" } } }, @@ -27863,26 +30026,25 @@ } } }, - "/api/messages/{id}/pin": { - "post": { - "operationId": "pin_message", + "/api/organizations/{organizationId}/inventory-warehouses/{id}": { + "get": { + "operationId": "inventory_warehouses_get", "tags": [ - "Message" + "InventoryWarehouse" ], "responses": { "200": { - "description": "Message resource created", + "description": "InventoryWarehouse resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Message.MessageOutput.jsonld" + "$ref": "#/components/schemas/InventoryWarehouse.InventoryWarehouseOutput.jsonld-inventory.read" } } - }, - "links": {} + } }, - "400": { - "description": "Invalid input", + "403": { + "description": "Forbidden", "content": { "application/ld+json": { "schema": { @@ -27902,8 +30064,8 @@ }, "links": {} }, - "403": { - "description": "Forbidden", + "404": { + "description": "Not found", "content": { "application/ld+json": { "schema": { @@ -27922,36 +30084,27 @@ } }, "links": {} - }, - "422": { - "description": "An error occurred", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" - } - } - }, - "links": {} } }, - "summary": "Creates a Message resource.", - "description": "Creates a Message resource.", + "summary": "Retrieves a InventoryWarehouse resource.", + "description": "Retrieves a InventoryWarehouse resource.", "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "InventoryWarehouse identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, { "name": "id", "in": "path", - "description": "Message identifier", + "description": "InventoryWarehouse identifier", "required": true, "deprecated": false, "schema": { @@ -27962,14 +30115,43 @@ } ] }, - "delete": { - "operationId": "unpin_message", + "patch": { + "operationId": "inventory_warehouses_patch", "tags": [ - "Message" + "InventoryWarehouse" ], "responses": { - "204": { - "description": "Message resource deleted" + "200": { + "description": "InventoryWarehouse resource updated", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/InventoryWarehouse.InventoryWarehouseOutput.jsonld-inventory.read" + } + } + }, + "links": {} + }, + "400": { + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} }, "403": { "description": "Forbidden", @@ -28012,15 +30194,48 @@ } }, "links": {} + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} } }, - "summary": "Removes the Message resource.", - "description": "Removes the Message resource.", + "summary": "Updates the InventoryWarehouse resource.", + "description": "Updates the InventoryWarehouse resource.", "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "InventoryWarehouse identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, { "name": "id", "in": "path", - "description": "Message identifier", + "description": "InventoryWarehouse identifier", "required": true, "deprecated": false, "schema": { @@ -28029,22 +30244,33 @@ "style": "simple", "explode": false } - ] + ], + "requestBody": { + "description": "The updated InventoryWarehouse resource", + "content": { + "application/merge-patch+json": { + "schema": { + "$ref": "#/components/schemas/InventoryWarehouse.PatchInventoryWarehouseInput-inventory.write.jsonMergePatch" + } + } + }, + "required": true + } } }, - "/api/messages/{id}/reactions": { + "/api/organizations/{organizationId}/inventory-corrections": { "post": { - "operationId": "add_reaction", + "operationId": "inventory_corrections_create", "tags": [ - "Message" + "Inventorycorrections" ], "responses": { - "200": { - "description": "Message resource created", + "201": { + "description": "Inventorycorrections resource created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Message.MessageOutput.jsonld" + "$ref": "#/components/schemas/Inventorycorrections.InventoryMovementOutput.jsonld-inventory.read" } } }, @@ -28114,13 +30340,13 @@ "links": {} } }, - "summary": "Creates a Message resource.", - "description": "Creates a Message resource.", + "summary": "Creates a Inventorycorrections resource.", + "description": "Creates a Inventorycorrections resource.", "parameters": [ { - "name": "id", + "name": "organizationId", "in": "path", - "description": "Message identifier", + "description": "Inventorycorrections identifier", "required": true, "deprecated": false, "schema": { @@ -28131,11 +30357,11 @@ } ], "requestBody": { - "description": "The new Message resource", + "description": "The new Inventorycorrections resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Message.AddReactionInput" + "$ref": "#/components/schemas/Inventorycorrections.CorrectInventoryStockInput-inventory.write" } } }, @@ -28143,18 +30369,26 @@ } } }, - "/api/messages/{id}/reactions/{emoji}": { - "delete": { - "operationId": "remove_reaction", + "/api/organizations/{organizationId}/inventory-returns": { + "post": { + "operationId": "inventory_returns_create", "tags": [ - "Message" + "Inventoryreturns" ], "responses": { - "204": { - "description": "Message resource deleted" + "201": { + "description": "Inventoryreturns resource created", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Inventoryreturns.InventoryMovementOutput.jsonld-inventory.read" + } + } + }, + "links": {} }, - "403": { - "description": "Forbidden", + "400": { + "description": "Invalid input", "content": { "application/ld+json": { "schema": { @@ -28174,8 +30408,8 @@ }, "links": {} }, - "404": { - "description": "Not found", + "403": { + "description": "Forbidden", "content": { "application/ld+json": { "schema": { @@ -28194,230 +30428,36 @@ } }, "links": {} - } - }, - "summary": "Removes the Message resource.", - "description": "Removes the Message resource.", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "Message identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - }, - { - "name": "emoji", - "in": "path", - "description": "Message identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - } - ] - } - }, - "/api/messages/{id}/replies": { - "get": { - "operationId": "list_replies", - "tags": [ - "Message" - ], - "responses": { - "200": { - "description": "Message collection", - "content": { - "application/ld+json": { - "schema": { - "type": "object", - "description": "Message.MessageOutput.jsonld collection.", - "allOf": [ - { - "$ref": "#/components/schemas/HydraCollectionBaseSchema" - }, - { - "type": "object", - "required": [ - "member" - ], - "properties": { - "member": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Message.MessageOutput.jsonld" - } - } - } - } - ] - } - } - } }, - "403": { - "description": "Forbidden", + "422": { + "description": "An error occurred", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Error.jsonld" + "$ref": "#/components/schemas/ConstraintViolation.jsonld" } }, "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/ConstraintViolation" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/ConstraintViolation" } } }, "links": {} } }, - "summary": "Retrieves the collection of Message resources.", - "description": "Retrieves the collection of Message resources.", + "summary": "Creates a Inventoryreturns resource.", + "description": "Creates a Inventoryreturns resource.", "parameters": [ { - "name": "id", + "name": "organizationId", "in": "path", - "description": "Message identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - }, - { - "name": "page", - "in": "query", - "description": "The collection page number", - "required": false, - "deprecated": false, - "schema": { - "type": "integer", - "default": 1 - }, - "style": "form", - "explode": true - }, - { - "name": "itemsPerPage", - "in": "query", - "description": "The number of items per page", - "required": false, - "deprecated": false, - "schema": { - "type": "integer", - "default": 30, - "minimum": 0, - "maximum": 100 - }, - "style": "form", - "explode": true - } - ] - }, - "post": { - "operationId": "post_reply", - "tags": [ - "Message" - ], - "responses": { - "201": { - "description": "Message resource created", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Message.MessageOutput.jsonld" - } - } - }, - "links": {} - }, - "400": { - "description": "Invalid input", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "links": {} - }, - "403": { - "description": "Forbidden", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "links": {} - }, - "422": { - "description": "An error occurred", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" - } - } - }, - "links": {} - } - }, - "summary": "Creates a Message resource.", - "description": "Creates a Message resource.", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "Message identifier", + "description": "Inventoryreturns identifier", "required": true, "deprecated": false, "schema": { @@ -28428,11 +30468,11 @@ } ], "requestBody": { - "description": "The new Message resource", + "description": "The new Inventoryreturns resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Message.PostReplyInput" + "$ref": "#/components/schemas/Inventoryreturns.ReturnInventoryConsumptionInput-inventory.write" } } }, @@ -28440,113 +30480,119 @@ } } }, - "/api/messages/{id}/save": { - "post": { - "operationId": "save_message", + "/api/maintenance/schedules/export": { + "get": { + "operationId": "maintenance_schedule_export", "tags": [ - "Message" + "Maintenance" ], "responses": { "200": { - "description": "Message resource created", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Message.MessageOutput.jsonld" - } - } - }, - "links": {} + "description": "CSV export streamed successfully" + }, + "204": { + "description": "MaintenanceSchedule resource" }, "400": { - "description": "Invalid input", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "links": {} + "description": "Missing organization filter" }, "403": { - "description": "Forbidden", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "links": {} + "description": "Authenticated but missing organization.maintenance.read" + }, + "404": { + "description": "The organization is outside the caller's scope" }, "422": { - "description": "An error occurred", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" - } - } - }, - "links": {} + "description": "Export exceeds the row cap; narrow the filters and retry" } }, - "summary": "Creates a Message resource.", - "description": "Creates a Message resource.", + "summary": "Export maintenance schedules (CSV)", + "description": "Streams a CSV export of maintenance schedules (Content-Disposition: attachment) for the given organization, using the same filters as the list endpoint (`facility`, `equipmentType`, `dueStatus`, `dueBefore`). Requires `organization.maintenance.read` on the organization, resolved the same way the list endpoint resolves it — a resource-level ROLE_USER check alone does not grant access. Bounded to 50000 matching rows — the request is rejected with 422 if the filters match more; narrow with a more specific filter and retry.", "parameters": [ { - "name": "id", - "in": "path", - "description": "Message identifier", + "name": "organization", + "in": "query", + "description": "Organization IRI.", "required": true, "deprecated": false, "schema": { "type": "string" }, - "style": "simple", - "explode": false + "style": "form", + "explode": true + }, + { + "name": "facility", + "in": "query", + "description": "Facility IRI filter.", + "required": false, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "form", + "explode": true + }, + { + "name": "equipmentType", + "in": "query", + "description": "Equipment type filter.", + "required": false, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "form", + "explode": true + }, + { + "name": "dueStatus", + "in": "query", + "description": "Due status filter (unscheduled|up_to_date|due_soon|overdue).", + "required": false, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "form", + "explode": true + }, + { + "name": "dueBefore", + "in": "query", + "description": "ISO-8601 upper bound on the next due date.", + "required": false, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "form", + "explode": true + } + ], + "security": [ + { + "bearerAuth": [] } ] - }, - "delete": { - "operationId": "unsave_message", + } + }, + "/api/organizations/{organizationId}/maintenance/engine": { + "get": { + "operationId": "maintenance_plan_engine", "tags": [ - "Message" + "Maintenance" ], "responses": { - "204": { - "description": "Message resource deleted" + "200": { + "description": "MaintenancePlan resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/MaintenancePlan.MaintenancePlanEngineOutput.jsonld-maintenance_plan.read" + } + } + } }, "403": { "description": "Forbidden", @@ -28591,13 +30637,13 @@ "links": {} } }, - "summary": "Removes the Message resource.", - "description": "Removes the Message resource.", + "summary": "Read active preventive engine", + "description": "Read active preventive engine", "parameters": [ { - "name": "id", + "name": "organizationId", "in": "path", - "description": "Message identifier", + "description": "MaintenancePlan identifier", "required": true, "deprecated": false, "schema": { @@ -28609,20 +30655,20 @@ ] } }, - "/api/saved-messages": { + "/api/organizations/{organizationId}/maintenance/plans": { "get": { - "operationId": "list_saved_messages", + "operationId": "maintenance_plans_list", "tags": [ - "Message" + "Maintenance" ], "responses": { "200": { - "description": "Message collection", + "description": "MaintenancePlan collection", "content": { "application/ld+json": { "schema": { "type": "object", - "description": "Message.MessageOutput.jsonld collection.", + "description": "MaintenancePlan.MaintenancePlanOutput.jsonld-maintenance_plan.read collection.", "allOf": [ { "$ref": "#/components/schemas/HydraCollectionBaseSchema" @@ -28636,7 +30682,7 @@ "member": { "type": "array", "items": { - "$ref": "#/components/schemas/Message.MessageOutput.jsonld" + "$ref": "#/components/schemas/MaintenancePlan.MaintenancePlanOutput.jsonld-maintenance_plan.read" } } } @@ -28668,9 +30714,21 @@ "links": {} } }, - "summary": "Retrieves the collection of Message resources.", - "description": "Retrieves the collection of Message resources.", + "summary": "List independent equipment operations", + "description": "List independent equipment operations", "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "MaintenancePlan identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, { "name": "page", "in": "query", @@ -28700,123 +30758,61 @@ "explode": true }, { - "name": "organization", + "name": "equipmentId", "in": "query", - "description": "Organization IRI.", - "required": true, + "description": "MaintenancePlan equipmentId", + "required": false, "deprecated": false, "schema": { - "type": "string" + "type": "string", + "format": "uuid" }, "style": "form", "explode": true - } - ] - } - }, - "/api/conversations/{conversationId}/attachments": { - "get": { - "operationId": "list_conversation_attachments", - "tags": [ - "Messaging" - ], - "responses": { - "200": { - "description": "Attachments retrieved", - "content": { - "application/ld+json": { - "schema": { - "type": "object", - "description": "MessagingAttachment.MessageAttachmentOutput.jsonld collection.", - "allOf": [ - { - "$ref": "#/components/schemas/HydraCollectionBaseSchema" - }, - { - "type": "object", - "required": [ - "member" - ], - "properties": { - "member": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MessagingAttachment.MessageAttachmentOutput.jsonld" - } - } - } - } - ] - } - } - } - }, - "403": { - "description": "Insufficient permissions" - }, - "404": { - "description": "Conversation not found" - } - }, - "summary": "List a conversation's attachments (Files tab)", - "description": "Lists the attachments posted in a conversation, most recently uploaded first.", - "parameters": [ - { - "name": "conversationId", - "in": "path", - "description": "MessagingAttachment identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false }, { - "name": "page", + "name": "operationKind", "in": "query", - "description": "The collection page number", + "description": "MaintenancePlan operationKind", "required": false, "deprecated": false, "schema": { - "type": "integer", - "default": 1 + "type": "string", + "enum": [ + "control", + "maintenance" + ] }, "style": "form", "explode": true }, { - "name": "itemsPerPage", + "name": "search", "in": "query", - "description": "The number of items per page", + "description": "MaintenancePlan search", "required": false, "deprecated": false, "schema": { - "type": "integer", - "default": 30, - "minimum": 0, - "maximum": 100 + "type": "string", + "maxLength": 160 }, "style": "form", "explode": true } ] - } - }, - "/api/messages/{messageId}/attachments": { + }, "post": { - "operationId": "add_message_attachment", + "operationId": "maintenance_plan_create", "tags": [ - "Messaging" + "Maintenance" ], "responses": { "201": { - "description": "Attachment uploaded", + "description": "MaintenancePlan resource created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/MessagingAttachment.MessageAttachmentOutput.jsonld" + "$ref": "#/components/schemas/MaintenancePlan.MaintenancePlanOutput.jsonld-maintenance_plan.read" } } }, @@ -28844,13 +30840,28 @@ "links": {} }, "403": { - "description": "Insufficient permissions" - }, - "404": { - "description": "Message not found" + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} }, "422": { - "description": "MIME type, size, or message state rejected", + "description": "An error occurred", "content": { "application/ld+json": { "schema": { @@ -28871,13 +30882,13 @@ "links": {} } }, - "summary": "Upload a message attachment", - "description": "Uploads a multipart file attachment to a message.", + "summary": "Prepare an equipment control or maintenance plan", + "description": "Prepare an equipment control or maintenance plan", "parameters": [ { - "name": "messageId", + "name": "organizationId", "in": "path", - "description": "MessagingAttachment identifier", + "description": "MaintenancePlan identifier", "required": true, "deprecated": false, "schema": { @@ -28886,39 +30897,109 @@ "style": "simple", "explode": false } - ] + ], + "requestBody": { + "description": "The new MaintenancePlan resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/MaintenancePlan.ChangeMaintenancePlanInput-maintenance_plan.write" + } + } + }, + "required": true + } } }, - "/api/messaging-attachments/{id}": { - "delete": { - "operationId": "delete_message_attachment", + "/api/organizations/{organizationId}/maintenance/plans/activate": { + "post": { + "operationId": "maintenance_plan_activate", "tags": [ - "Messaging" + "Maintenance" ], "responses": { - "204": { - "description": "Attachment deleted" - }, - "403": { - "description": "Insufficient permissions" + "200": { + "description": "MaintenancePlan resource created", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/MaintenancePlan.MaintenancePlanEngineOutput.jsonld-maintenance_plan.read" + } + } + }, + "links": {} }, - "404": { - "description": "Attachment not found" + "400": { + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} }, - "412": { - "description": "Attachment revision is stale" + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} }, - "428": { - "description": "If-Match header is required" + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} } }, - "summary": "Delete a message attachment", - "description": "Delete a message attachment", + "summary": "Atomically hand over the preventive engine", + "description": "Atomically hand over the preventive engine", "parameters": [ { - "name": "id", + "name": "organizationId", "in": "path", - "description": "MessagingAttachment identifier", + "description": "MaintenancePlan identifier", "required": true, "deprecated": false, "schema": { @@ -28930,80 +31011,44 @@ ] } }, - "/api/messaging-attachments/{id}/content": { - "get": { - "operationId": "download_message_attachment", + "/api/organizations/{organizationId}/maintenance/plans/prepare-legacy": { + "post": { + "operationId": "maintenance_plan_prepare_legacy", "tags": [ - "Messaging" + "Maintenance" ], "responses": { "200": { - "description": "Attachment file content" - }, - "204": { - "description": "MessagingAttachmentContent resource" - }, - "403": { - "description": "Insufficient permissions" - }, - "404": { - "description": "Attachment (or its stored content) not found" - } - }, - "summary": "Download a message attachment", - "description": "Streams the stored file bytes with Content-Disposition: attachment. Gated by the same access rule as reading the owning conversation.", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "MessagingAttachmentContent identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" + "description": "MaintenancePlan resource created", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/MaintenancePlan.MaintenancePlanEngineOutput.jsonld-maintenance_plan.read" + } + } }, - "style": "simple", - "explode": false - } - ] - } - }, - "/api/conversations/{conversationId}/links": { - "get": { - "operationId": "list_conversation_links", - "tags": [ - "MessagingLink" - ], - "responses": { - "200": { - "description": "MessagingLink collection", + "links": {} + }, + "400": { + "description": "Invalid input", "content": { "application/ld+json": { "schema": { - "type": "object", - "description": "MessagingLink.MessagingLinkOutput.jsonld collection.", - "allOf": [ - { - "$ref": "#/components/schemas/HydraCollectionBaseSchema" - }, - { - "type": "object", - "required": [ - "member" - ], - "properties": { - "member": { - "type": "array", - "items": { - "$ref": "#/components/schemas/MessagingLink.MessagingLinkOutput.jsonld" - } - } - } - } - ] + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" } } - } + }, + "links": {} }, "403": { "description": "Forbidden", @@ -29025,15 +31070,36 @@ } }, "links": {} + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} } }, - "summary": "Retrieves the collection of MessagingLink resources.", - "description": "Retrieves the collection of MessagingLink resources.", + "summary": "Prepare historical plans without generating work", + "description": "Prepare historical plans without generating work", "parameters": [ { - "name": "conversationId", + "name": "organizationId", "in": "path", - "description": "MessagingLink identifier", + "description": "MaintenancePlan identifier", "required": true, "deprecated": false, "schema": { @@ -29041,78 +31107,27 @@ }, "style": "simple", "explode": false - }, - { - "name": "page", - "in": "query", - "description": "The collection page number", - "required": false, - "deprecated": false, - "schema": { - "type": "integer", - "default": 1 - }, - "style": "form", - "explode": true - }, - { - "name": "itemsPerPage", - "in": "query", - "description": "The number of items per page", - "required": false, - "deprecated": false, - "schema": { - "type": "integer", - "default": 30, - "minimum": 0, - "maximum": 100 - }, - "style": "form", - "explode": true } ] } }, - "/api/notification-types": { + "/api/organizations/{organizationId}/maintenance/plans/{id}": { "get": { - "operationId": "notification_types_list", + "operationId": "maintenance_plan_get", "tags": [ - "Notification" + "Maintenance" ], "responses": { "200": { - "description": "Notification types retrieved successfully", + "description": "MaintenancePlan resource", "content": { "application/ld+json": { "schema": { - "type": "object", - "description": "NotificationType.NotificationTypeOutput.jsonld-notification_type.read collection.", - "allOf": [ - { - "$ref": "#/components/schemas/HydraCollectionBaseSchema" - }, - { - "type": "object", - "required": [ - "member" - ], - "properties": { - "member": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NotificationType.NotificationTypeOutput.jsonld-notification_type.read" - } - } - } - } - ] + "$ref": "#/components/schemas/MaintenancePlan.MaintenancePlanOutput.jsonld-maintenance_plan.read" } } } }, - "401": { - "description": "Authentication required" - }, "403": { "description": "Forbidden", "content": { @@ -29133,74 +31148,9 @@ } }, "links": {} - } - }, - "summary": "List notification types", - "description": "Returns all known notification types with their categories. Useful for building type selector components on the frontend.", - "parameters": [ - { - "name": "page", - "in": "query", - "description": "The collection page number", - "required": false, - "deprecated": false, - "schema": { - "type": "integer", - "default": 1 - }, - "style": "form", - "explode": true - } - ], - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/api/notifications": { - "get": { - "operationId": "notifications_list", - "tags": [ - "Notification" - ], - "responses": { - "200": { - "description": "Notifications retrieved successfully", - "content": { - "application/ld+json": { - "schema": { - "type": "object", - "description": "Notification.NotificationOutput.jsonld-notification.read collection.", - "allOf": [ - { - "$ref": "#/components/schemas/HydraCollectionBaseSchema" - }, - { - "type": "object", - "required": [ - "member" - ], - "properties": { - "member": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Notification.NotificationOutput.jsonld-notification.read" - } - } - } - } - ] - } - } - } - }, - "401": { - "description": "Authentication required" }, - "403": { - "description": "Forbidden", + "404": { + "description": "Not found", "content": { "application/ld+json": { "schema": { @@ -29221,113 +31171,43 @@ "links": {} } }, - "summary": "List notifications", - "description": "Returns a paginated collection of notifications for the authenticated user. Read notifications from low-value categories may be omitted from the default list after a retention delay.", + "summary": "Read an equipment operation", + "description": "Read an equipment operation", "parameters": [ { - "name": "page", - "in": "query", - "description": "The collection page number", - "required": false, - "deprecated": false, - "schema": { - "type": "integer", - "default": 1 - }, - "style": "form", - "explode": true - }, - { - "name": "itemsPerPage", - "in": "query", - "description": "The number of items per page", - "required": false, - "deprecated": false, - "schema": { - "type": "integer", - "default": 20, - "minimum": 0, - "maximum": 100 - }, - "style": "form", - "explode": true - }, - { - "name": "unreadOnly", - "in": "query", - "description": "When true, returns only unread notifications.", - "required": false, - "deprecated": false, - "schema": { - "type": "boolean" - }, - "style": "form", - "explode": true - }, - { - "name": "type", - "in": "query", - "description": "Filter by exact notification type (e.g. `organization.invitation`). See `GET /notification-types` for the full list.", - "required": false, + "name": "organizationId", + "in": "path", + "description": "MaintenancePlan identifier", + "required": true, "deprecated": false, "schema": { "type": "string" }, - "style": "form", - "explode": true + "style": "simple", + "explode": false }, { - "name": "category", - "in": "query", - "description": "Filter by category prefix (e.g. `organization`, `system`). Ignored when `type` is also provided.", - "required": false, + "name": "id", + "in": "path", + "description": "MaintenancePlan identifier", + "required": true, "deprecated": false, "schema": { "type": "string" }, - "style": "form", - "explode": true - }, - { - "name": "organization", - "in": "query", - "description": "Filter by organization identifier. When omitted, notifications across all organizations are returned, including account-level notifications that have no organization.", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "format": "uuid" - }, - "style": "form", - "explode": true - } - ], - "security": [ - { - "bearerAuth": [] + "style": "simple", + "explode": false } ] - } - }, - "/api/notifications/preferences": { - "get": { - "operationId": "notification_preferences_get", + }, + "delete": { + "operationId": "maintenance_plan_archive", "tags": [ - "Notification" + "Maintenance" ], "responses": { - "200": { - "description": "Notification preferences retrieved successfully", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Notification.NotificationPreferencesOutput.jsonld-notification.preferences.read" - } - } - } - }, - "401": { - "description": "Authentication required" + "204": { + "description": "MaintenancePlan resource deleted" }, "403": { "description": "Forbidden", @@ -29372,34 +31252,54 @@ "links": {} } }, - "summary": "Get notification preferences", - "description": "Returns the authenticated user's customized per-category delivery preferences. A category with no entry is enabled on every channel (email and Mercure); only explicit customizations are stored.", - "parameters": [], - "security": [ + "summary": "Archive an operation, retaining history", + "description": "Archive an operation, retaining history", + "parameters": [ { - "bearerAuth": [] + "name": "organizationId", + "in": "path", + "description": "MaintenancePlan identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "id", + "in": "path", + "description": "MaintenancePlan identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false } ] }, "patch": { - "operationId": "notification_preferences_update", + "operationId": "maintenance_plan_update", "tags": [ - "Notification" + "Maintenance" ], "responses": { "200": { - "description": "Notification preferences updated successfully", + "description": "MaintenancePlan resource updated", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Notification.NotificationPreferencesOutput.jsonld-notification.preferences.read" + "$ref": "#/components/schemas/MaintenancePlan.MaintenancePlanOutput.jsonld-maintenance_plan.read" } } }, "links": {} }, "400": { - "description": "Invalid request - validation failed", + "description": "Invalid input", "content": { "application/ld+json": { "schema": { @@ -29419,9 +31319,6 @@ }, "links": {} }, - "401": { - "description": "Authentication required" - }, "403": { "description": "Forbidden", "content": { @@ -29486,40 +31383,60 @@ "links": {} } }, - "summary": "Update notification preferences", - "description": "Upserts one or more per-category delivery preferences for the authenticated user and returns the full customized set. This suppresses email/Mercure delivery only — matching notifications are still persisted and remain visible in the in-app list.", - "parameters": [], + "summary": "Update an equipment operation", + "description": "Update an equipment operation", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "MaintenancePlan identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "id", + "in": "path", + "description": "MaintenancePlan identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], "requestBody": { - "description": "The updated Notification resource", + "description": "The updated MaintenancePlan resource", "content": { "application/merge-patch+json": { "schema": { - "$ref": "#/components/schemas/Notification.UpdateNotificationPreferencesInput-notification.preferences.write.jsonMergePatch" + "$ref": "#/components/schemas/MaintenancePlan.ChangeMaintenancePlanInput-maintenance_plan.write.jsonMergePatch" } } }, "required": true - }, - "security": [ - { - "bearerAuth": [] - } - ] + } } }, - "/api/notifications/read-all": { - "patch": { - "operationId": "notification_mark_all_as_read", + "/api/organizations/{organizationId}/maintenance/plans/{id}/generate": { + "post": { + "operationId": "maintenance_plan_generate", "tags": [ - "Notification" + "Maintenance" ], "responses": { "200": { - "description": "Notifications marked as read", + "description": "MaintenancePlan resource created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Notification.MarkAllNotificationsAsReadOutput.jsonld-notification.mark_all_as_read" + "$ref": "#/components/schemas/MaintenancePlan.GenerateMaintenancePlanOutput.jsonld-maintenance_plan.read" } } }, @@ -29546,9 +31463,6 @@ }, "links": {} }, - "401": { - "description": "Authentication required" - }, "403": { "description": "Forbidden", "content": { @@ -29570,27 +31484,6 @@ }, "links": {} }, - "404": { - "description": "Not found", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "links": {} - }, "422": { "description": "An error occurred", "content": { @@ -29613,50 +31506,64 @@ "links": {} } }, - "summary": "Mark all notifications as read", - "description": "Marks every unread notification of the authenticated user as read. Idempotent: calling it again once everything is read affects zero notifications.", + "summary": "Reserve an occurrence and generate or replay its work", + "description": "Reserve an occurrence and generate or replay its work", "parameters": [ { - "name": "organization", - "in": "query", - "description": "Filter by organization identifier. When omitted, notifications across all organizations (and account-level ones) are marked as read.", - "required": false, + "name": "organizationId", + "in": "path", + "description": "MaintenancePlan identifier", + "required": true, "deprecated": false, "schema": { - "type": "string", - "format": "uuid" + "type": "string" }, - "style": "form", - "explode": true - } - ], - "security": [ + "style": "simple", + "explode": false + }, { - "bearerAuth": [] + "name": "id", + "in": "path", + "description": "MaintenancePlan identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false } - ] + ], + "requestBody": { + "description": "The new MaintenancePlan resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/MaintenancePlan.GenerateMaintenancePlanInput-maintenance_plan.write" + } + } + }, + "required": true + } } }, - "/api/notifications/subscription": { + "/api/organizations/{organizationId}/maintenance/plans/{id}/preview": { "get": { - "operationId": "notification_mercure_subscription", + "operationId": "maintenance_plan_preview", "tags": [ - "Notification" + "Maintenance" ], "responses": { "200": { - "description": "Mercure subscription token returned successfully", + "description": "MaintenancePlan resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Notification.MercureSubscriptionOutput.jsonld-notification.mercure_subscription" + "$ref": "#/components/schemas/MaintenancePlan.MaintenancePlanPreviewOutput.jsonld-maintenance_plan.read" } } } }, - "401": { - "description": "Authentication required" - }, "403": { "description": "Forbidden", "content": { @@ -29700,123 +31607,73 @@ "links": {} } }, - "summary": "Get Mercure subscription token", - "description": "Returns a Mercure subscriber JWT and the private SSE topic for the authenticated user. The client must pass this token in the Authorization header (Bearer) when opening an EventSource connection to the Mercure hub.", - "parameters": [], - "security": [ + "summary": "Preview three dates before activation", + "description": "Preview three dates before activation", + "parameters": [ { - "bearerAuth": [] - } - ] - } - }, - "/api/notifications/unread-count": { - "get": { - "operationId": "notification_unread_count", - "tags": [ - "Notification" - ], - "responses": { - "200": { - "description": "Unread notification count returned successfully", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Notification.UnreadNotificationsCountOutput.jsonld-notification.unread_count" - } - } - } - }, - "401": { - "description": "Authentication required" - }, - "403": { - "description": "Forbidden", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } + "name": "organizationId", + "in": "path", + "description": "MaintenancePlan identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" }, - "links": {} + "style": "simple", + "explode": false }, - "404": { - "description": "Not found", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "links": {} - } - }, - "summary": "Get unread notifications count", - "description": "Returns the unread notification count for the authenticated user.", - "parameters": [ { - "name": "organization", - "in": "query", - "description": "Filter by organization identifier. When omitted, unread notifications across all organizations (and account-level ones) are counted.", - "required": false, + "name": "id", + "in": "path", + "description": "MaintenancePlan identifier", + "required": true, "deprecated": false, "schema": { - "type": "string", - "format": "uuid" + "type": "string" }, - "style": "form", - "explode": true - } - ], - "security": [ - { - "bearerAuth": [] + "style": "simple", + "explode": false } ] } }, - "/api/notifications/{id}": { + "/api/organizations/{organizationId}/maintenance-export-references": { "get": { - "operationId": "notification_get", + "operationId": "maintenance_export_references", "tags": [ - "Notification" + "Maintenance export" ], "responses": { "200": { - "description": "Notification retrieved successfully", + "description": "MaintenanceExport collection", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Notification.NotificationOutput.jsonld-notification.read" + "type": "object", + "description": "MaintenanceExport.MaintenanceExportReferenceOutput.jsonld-maintenance_export.read collection.", + "allOf": [ + { + "$ref": "#/components/schemas/HydraCollectionBaseSchema" + }, + { + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MaintenanceExport.MaintenanceExportReferenceOutput.jsonld-maintenance_export.read" + } + } + } + } + ] } } } }, - "401": { - "description": "Authentication required" - }, "403": { "description": "Forbidden", "content": { @@ -29837,18 +31694,15 @@ } }, "links": {} - }, - "404": { - "description": "Notification not found" } }, - "summary": "Get notification", - "description": "Returns a notification owned by the authenticated user.", + "summary": "Read scoped external identity mappings", + "description": "Read scoped external identity mappings", "parameters": [ { - "name": "id", + "name": "organizationId", "in": "path", - "description": "Notification identifier", + "description": "MaintenanceExport identifier", "required": true, "deprecated": false, "schema": { @@ -29856,28 +31710,93 @@ }, "style": "simple", "explode": false - } - ], - "security": [ + }, { - "bearerAuth": [] + "name": "page", + "in": "query", + "description": "The collection page number", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 1 + }, + "style": "form", + "explode": true + }, + { + "name": "itemsPerPage", + "in": "query", + "description": "The number of items per page", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 30, + "minimum": 0, + "maximum": 100 + }, + "style": "form", + "explode": true + }, + { + "name": "system", + "in": "query", + "description": "MaintenanceExport system", + "required": false, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "form", + "explode": true + }, + { + "name": "resourceType", + "in": "query", + "description": "MaintenanceExport resourceType", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "enum": [ + "customer", + "site", + "equipment" + ] + }, + "style": "form", + "explode": true + }, + { + "name": "resourceId", + "in": "query", + "description": "MaintenanceExport resourceId", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "uuid" + }, + "style": "form", + "explode": true } ] } }, - "/api/notifications/{id}/read": { + "/api/organizations/{organizationId}/maintenance-export-references/{resourceType}/{resourceId}": { "patch": { - "operationId": "notification_mark_as_read", + "operationId": "write_maintenance_export_reference", "tags": [ - "Notification" + "Maintenance export" ], "responses": { "200": { - "description": "Notification marked as read", + "description": "MaintenanceExport resource updated", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Notification.NotificationOutput.jsonld-notification.read" + "$ref": "#/components/schemas/MaintenanceExport.MaintenanceExportReferenceOutput.jsonld-maintenance_export.read" } } }, @@ -29904,9 +31823,6 @@ }, "links": {} }, - "401": { - "description": "Authentication required" - }, "403": { "description": "Forbidden", "content": { @@ -29929,7 +31845,25 @@ "links": {} }, "404": { - "description": "Notification not found" + "description": "Not found", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} }, "422": { "description": "An error occurred", @@ -29953,13 +31887,13 @@ "links": {} } }, - "summary": "Mark notification as read", - "description": "Marks one notification as read for the authenticated user.", + "summary": "Set an external identity reference with an optimistic revision", + "description": "Set an external identity reference with an optimistic revision", "parameters": [ { - "name": "id", + "name": "organizationId", "in": "path", - "description": "Notification identifier", + "description": "MaintenanceExport identifier", "required": true, "deprecated": false, "schema": { @@ -29967,44 +31901,84 @@ }, "style": "simple", "explode": false - } - ], - "security": [ + }, { - "bearerAuth": [] + "name": "resourceType", + "in": "path", + "description": "MaintenanceExport identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "resourceId", + "in": "path", + "description": "MaintenanceExport identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false } - ] + ], + "requestBody": { + "description": "The updated MaintenanceExport resource", + "content": { + "application/merge-patch+json": { + "schema": { + "$ref": "#/components/schemas/MaintenanceExport.WriteMaintenanceExportReferenceInput-maintenance_export.write.jsonMergePatch" + } + } + }, + "required": true + } } }, - "/api/.well-known/jwks.json": { + "/api/organizations/{organizationId}/maintenance-export-sources": { "get": { - "operationId": "jwks", + "operationId": "maintenance_export_sources", "tags": [ - "OAuth2" + "Maintenance export" ], "responses": { "200": { - "description": "JWKS returned successfully", + "description": "MaintenanceExport collection", "content": { - "application/json": { + "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Discovery.JwksOutput-token.read" + "type": "object", + "description": "MaintenanceExport.MaintenanceExportSourceOutput.jsonld-maintenance_export.read collection.", + "allOf": [ + { + "$ref": "#/components/schemas/HydraCollectionBaseSchema" + }, + { + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MaintenanceExport.MaintenanceExportSourceOutput.jsonld-maintenance_export.read" + } + } + } + } + ] } } - }, - "links": { - "GetOpenIDConfig": { - "operationId": "openid_configuration", - "description": "Get the full OpenID Connect configuration" - }, - "IntrospectToken": { - "operationId": "introspect_token", - "description": "Introspect a token to validate it server-side" - } } }, - "404": { - "description": "Not found", + "403": { + "description": "Forbidden", "content": { "application/ld+json": { "schema": { @@ -30025,123 +31999,103 @@ "links": {} } }, - "summary": "JSON Web Key Set", - "description": "Returns the public keys used to verify JWT signatures as defined in RFC 7517. Use these keys to validate access tokens issued by this server.", - "parameters": [] + "summary": "List published dossiers with explicit snapshot availability", + "description": "List published dossiers with explicit snapshot availability", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "MaintenanceExport identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "page", + "in": "query", + "description": "The collection page number", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 1 + }, + "style": "form", + "explode": true + }, + { + "name": "itemsPerPage", + "in": "query", + "description": "The number of items per page", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 30, + "minimum": 0, + "maximum": 100 + }, + "style": "form", + "explode": true + }, + { + "name": "search", + "in": "query", + "description": "MaintenanceExport search", + "required": false, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "form", + "explode": true + } + ] } }, - "/api/.well-known/openid-configuration": { + "/api/organizations/{organizationId}/maintenance-exports": { "get": { - "operationId": "openid_configuration", + "operationId": "maintenance_exports", "tags": [ - "OAuth2" + "Maintenance export" ], "responses": { "200": { - "description": "OpenID Connect configuration returned successfully", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Discovery.OpenIdConfigurationOutput-token.read" - } - } - }, - "links": { - "GetJWKS": { - "operationId": "jwks", - "description": "Get the JSON Web Key Set for signature verification", - "parameters": { - "url": "$response.body#/jwks_uri" - } - }, - "IssueToken": { - "operationId": "token", - "description": "Issue an access token using the token endpoint", - "parameters": { - "url": "$response.body#/token_endpoint" - } - }, - "IntrospectToken": { - "operationId": "introspect_token", - "description": "Introspect a token using the introspection endpoint", - "parameters": { - "url": "$response.body#/introspection_endpoint" - } - }, - "RevokeToken": { - "operationId": "revoke_token", - "description": "Revoke a token using the revocation endpoint", - "parameters": { - "url": "$response.body#/revocation_endpoint" - } - }, - "GetUserInfo": { - "operationId": "userinfo", - "description": "Get user information using the userinfo endpoint", - "parameters": { - "url": "$response.body#/userinfo_endpoint" - } - }, - "EndSession": { - "operationId": "end_session", - "description": "Terminate the OpenID Connect session", - "parameters": { - "url": "$response.body#/end_session_endpoint" - } - } - } - }, - "404": { - "description": "Not found", + "description": "MaintenanceExport collection", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" + "type": "object", + "description": "MaintenanceExport.MaintenanceExportOutput.jsonld-maintenance_export.read collection.", + "allOf": [ + { + "$ref": "#/components/schemas/HydraCollectionBaseSchema" + }, + { + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MaintenanceExport.MaintenanceExportOutput.jsonld-maintenance_export.read" + } + } + } + } + ] } } - }, - "links": {} - } - }, - "summary": "OAuth2/OIDC Discovery", - "description": "Returns the OAuth2 Authorization Server Metadata document (RFC 8414) with optional OpenID Connect extensions when available.", - "parameters": [] - } - }, - "/api/oauth2/authorize": { - "get": { - "operationId": "authorize", - "tags": [ - "OAuth2" - ], - "responses": { - "204": { - "description": "OAuth2 resource" - }, - "302": { - "description": "Authorization code issued (redirect to redirect_uri)." - }, - "400": { - "description": "Invalid authorization request." - }, - "401": { - "description": "Authentication required." + } }, "403": { - "description": "User consent required." - }, - "404": { - "description": "Not found", + "description": "Forbidden", "content": { "application/ld+json": { "schema": { @@ -30160,185 +32114,84 @@ } }, "links": {} - }, - "429": { - "description": "Too many authorization requests" } }, - "summary": "Authorize Client", - "description": "Initiate the OAuth2 authorization code flow (PKCE required).", + "summary": "List preserved ERP prestation exports", + "description": "List preserved ERP prestation exports", "parameters": [ { - "name": "response_type", - "in": "query", - "description": "OAuth2 response type (must be code)", + "name": "organizationId", + "in": "path", + "description": "MaintenanceExport identifier", "required": true, "deprecated": false, "schema": { - "type": "string", - "enum": [ - "code" - ] + "type": "string" }, - "style": "form", - "explode": true + "style": "simple", + "explode": false }, { - "name": "client_id", + "name": "page", "in": "query", - "description": "OAuth2 client identifier (UUID)", - "required": true, + "description": "The collection page number", + "required": false, "deprecated": false, "schema": { - "type": "string", - "format": "uuid" + "type": "integer", + "default": 1 }, "style": "form", "explode": true }, { - "name": "redirect_uri", + "name": "itemsPerPage", "in": "query", - "description": "Redirect URI registered for the client", - "required": true, + "description": "The number of items per page", + "required": false, "deprecated": false, "schema": { - "type": "string", - "format": "uri" + "type": "integer", + "default": 30, + "minimum": 0, + "maximum": 100 }, "style": "form", "explode": true }, { - "name": "scope", + "name": "system", "in": "query", - "description": "Space-separated list of requested scopes", + "description": "MaintenanceExport system", "required": false, "deprecated": false, "schema": { - "type": "string", - "example": "openid profile email" + "type": "string" }, "style": "form", "explode": true - }, - { - "name": "state", - "in": "query", - "description": "Opaque state value returned to the client", - "required": false, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "form", - "explode": true - }, - { - "name": "code_challenge", - "in": "query", - "description": "PKCE code challenge", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "form", - "explode": true - }, - { - "name": "code_challenge_method", - "in": "query", - "description": "PKCE code challenge method (S256 or plain, defaults to plain)", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "enum": [ - "S256", - "plain" - ] - }, - "style": "form", - "explode": true - }, - { - "name": "nonce", - "in": "query", - "description": "OIDC nonce value (optional)", - "required": false, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "form", - "explode": true - }, - { - "name": "prompt", - "in": "query", - "description": "OIDC prompt values (space-separated)", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "example": "login consent" - }, - "style": "form", - "explode": true - }, - { - "name": "max_age", - "in": "query", - "description": "Maximum authentication age in seconds", - "required": false, - "deprecated": false, - "schema": { - "type": "integer", - "example": 3600 - }, - "style": "form", - "explode": true - } - ], - "security": [ - { - "bearerAuth": [] } ] - } - }, - "/api/oauth2/consent/check": { - "get": { - "operationId": "check_consent", + }, + "post": { + "operationId": "create_maintenance_export", "tags": [ - "OAuth2" + "Maintenance export" ], "responses": { - "200": { - "description": "Consent check result", + "201": { + "description": "MaintenanceExport resource created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/OAuth2.CheckConsentOutput.jsonld-consent.read" + "$ref": "#/components/schemas/MaintenanceExport.MaintenanceExportOutput.jsonld-maintenance_export.read" } } }, - "links": { - "GetUserInfo": { - "operationId": "userinfo", - "description": "Get user information after consent check" - } - } + "links": {} }, "400": { - "description": "Missing required client_id parameter" - }, - "401": { - "description": "Missing or invalid access token" - }, - "404": { - "description": "Not found", + "description": "Invalid input", "content": { "application/ld+json": { "schema": { @@ -30358,64 +32211,8 @@ }, "links": {} }, - "429": { - "description": "Too many consent checks" - } - }, - "summary": "Check User Consent", - "description": "Check if the authenticated user has granted consent for a specific client and scopes.", - "parameters": [ - { - "name": "client_id", - "in": "query", - "description": "The OAuth2 client identifier requesting authorization", - "required": true, - "deprecated": false, - "schema": { - "type": "string", - "format": "uuid", - "example": "01234567-89ab-cdef-0123-456789abcdef" - }, - "style": "form", - "explode": true - }, - { - "name": "scope", - "in": "query", - "description": "Space-separated list of requested OAuth2 scopes", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "example": "openid profile email" - }, - "style": "form", - "explode": true - } - ], - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/api/oauth2/consent/grant": { - "post": { - "operationId": "grant_consent", - "tags": [ - "OAuth2" - ], - "responses": { - "204": { - "description": "OAuth2 resource created", - "links": {} - }, - "302": { - "description": "Authorization completed (redirect to redirect_uri)." - }, - "400": { - "description": "Invalid consent request.", + "403": { + "description": "Forbidden", "content": { "application/ld+json": { "schema": { @@ -30435,9 +32232,6 @@ }, "links": {} }, - "401": { - "description": "Authentication required." - }, "422": { "description": "An error occurred", "content": { @@ -30458,60 +32252,74 @@ } }, "links": {} - }, - "429": { - "description": "Too many consent submissions" } }, - "summary": "Grant User Consent", - "description": "Approve or deny a consent request and complete the authorization flow.", - "parameters": [], + "summary": "Preserve versioned CSV and JSON bytes atomically", + "description": "Preserve versioned CSV and JSON bytes atomically", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "MaintenanceExport identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], "requestBody": { - "description": "The new OAuth2 resource", + "description": "The new MaintenanceExport resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/OAuth2.GrantConsentInput-consent.write" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/OAuth2.GrantConsentInput-consent.write" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/OAuth2.GrantConsentInput-consent.write" + "$ref": "#/components/schemas/MaintenanceExport.CreateMaintenanceExportInput-maintenance_export.write" } } }, "required": true - }, - "security": [ - { - "bearerAuth": [] - } - ] + } } }, - "/api/oauth2/logout": { + "/api/organizations/{organizationId}/maintenance-exports/{id}": { "get": { - "operationId": "end_session", + "operationId": "maintenance_export", "tags": [ - "OAuth2" + "Maintenance export" ], "responses": { "200": { - "description": "Logout completed without redirect." - }, - "204": { - "description": "OAuth2 resource" - }, - "302": { - "description": "Redirect to the post_logout_redirect_uri." + "description": "MaintenanceExport resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/MaintenanceExport.MaintenanceExportOutput.jsonld-maintenance_export.read" + } + } + } }, - "400": { - "description": "Invalid logout request." + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} }, "404": { "description": "Not found", @@ -30535,111 +32343,77 @@ "links": {} } }, - "summary": "End Session", - "description": "Terminate the current OpenID Connect session and optionally redirect to a client post-logout URI.", + "summary": "Read retained export metadata", + "description": "Read retained export metadata", "parameters": [ { - "name": "id_token_hint", - "in": "query", - "description": "ID token previously issued by this server (optional)", - "required": false, + "name": "organizationId", + "in": "path", + "description": "MaintenanceExport identifier", + "required": true, "deprecated": false, "schema": { "type": "string" }, - "style": "form", - "explode": true - }, - { - "name": "post_logout_redirect_uri", - "in": "query", - "description": "Client post-logout redirect URI (must be registered)", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "format": "uri" - }, - "style": "form", - "explode": true - }, - { - "name": "client_id", - "in": "query", - "description": "OAuth2 client identifier (required when post_logout_redirect_uri is provided)", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "format": "uuid" - }, - "style": "form", - "explode": true + "style": "simple", + "explode": false }, { - "name": "state", - "in": "query", - "description": "Opaque state value returned to the client after logout", - "required": false, + "name": "id", + "in": "path", + "description": "MaintenanceExport identifier", + "required": true, "deprecated": false, "schema": { "type": "string" }, - "style": "form", - "explode": true + "style": "simple", + "explode": false } ] } }, - "/api/oauth2/token": { + "/api/organizations/{organizationId}/maintenance-exports/{id}/adjustments": { "post": { - "operationId": "token", + "operationId": "adjust_maintenance_export", "tags": [ - "OAuth2" + "Maintenance export" ], "responses": { - "200": { - "description": "Access token issued successfully", + "201": { + "description": "MaintenanceExport resource created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/OAuth2.TokenOutput.jsonld-token.read" + "$ref": "#/components/schemas/MaintenanceExport.MaintenanceExportOutput.jsonld-maintenance_export.read" } } }, - "links": { - "IntrospectToken": { - "operationId": "introspect_token", - "description": "Introspect the issued token to get its metadata", - "parameters": { - "token": "$response.body#/access_token", - "token_type_hint": "access_token" + "links": {} + }, + "400": { + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" } }, - "RevokeToken": { - "operationId": "revoke_token", - "description": "Revoke the issued token when no longer needed", - "parameters": { - "token": "$response.body#/access_token", - "token_type_hint": "access_token" + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" } }, - "RevokeRefreshToken": { - "operationId": "revoke_token", - "description": "Revoke the refresh token when no longer needed", - "parameters": { - "token": "$response.body#/refresh_token", - "token_type_hint": "refresh_token" + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" } - }, - "GetUserInfo": { - "operationId": "userinfo", - "description": "Get user information using the access token (pass as Bearer header)" } - } + }, + "links": {} }, - "400": { - "description": "Invalid request - missing or invalid parameters", + "403": { + "description": "Forbidden", "content": { "application/ld+json": { "schema": { @@ -30659,9 +32433,6 @@ }, "links": {} }, - "401": { - "description": "Invalid client credentials or unauthorized grant type" - }, "422": { "description": "An error occurred", "content": { @@ -30682,30 +32453,42 @@ } }, "links": {} - }, - "429": { - "description": "Too many token requests" } }, - "summary": "Issue Access Token", - "description": "Issue an access token using configured OAuth2 grant types (see discovery metadata).", - "parameters": [], + "summary": "Append an adjustment without rewriting original files", + "description": "Append an adjustment without rewriting original files", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "MaintenanceExport identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "id", + "in": "path", + "description": "MaintenanceExport identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], "requestBody": { - "description": "The new OAuth2 resource", + "description": "The new MaintenanceExport resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/OAuth2.TokenInput-token.write" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/OAuth2.TokenInput-token.write" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/OAuth2.TokenInput-token.write" + "$ref": "#/components/schemas/MaintenanceExport.AdjustMaintenanceExportInput-maintenance_export.write" } } }, @@ -30713,32 +32496,23 @@ } } }, - "/api/oauth2/token/introspect": { + "/api/organizations/{organizationId}/maintenance-exports/{id}/confirm": { "post": { - "operationId": "introspect_token", + "operationId": "confirm_maintenance_export", "tags": [ - "OAuth2" + "Maintenance export" ], "responses": { "200": { - "description": "Token introspection result", + "description": "MaintenanceExport resource created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/OAuth2.TokenIntrospectionOutput.jsonld-token.read" + "$ref": "#/components/schemas/MaintenanceExport.MaintenanceExportOutput.jsonld-maintenance_export.read" } } }, - "links": { - "RevokeToken": { - "operationId": "revoke_token", - "description": "Revoke the token if it should be invalidated", - "parameters": { - "token": "$request.body#/token", - "token_type_hint": "$request.body#/token_type_hint" - } - } - } + "links": {} }, "400": { "description": "Invalid input", @@ -30761,9 +32535,6 @@ }, "links": {} }, - "401": { - "description": "Invalid client credentials" - }, "403": { "description": "Forbidden", "content": { @@ -30805,60 +32576,61 @@ } }, "links": {} - }, - "429": { - "description": "Too many introspection requests" } }, - "summary": "Introspect Token", - "description": "Query the authorization server about the current state and metadata of a token as defined in RFC 7662.", - "parameters": [], + "summary": "Explicitly acknowledge the external import", + "description": "Explicitly acknowledge the external import", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "MaintenanceExport identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "id", + "in": "path", + "description": "MaintenanceExport identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], "requestBody": { - "description": "The new OAuth2 resource", + "description": "The new MaintenanceExport resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/OAuth2.TokenIntrospectionInput-token.write" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/OAuth2.TokenIntrospectionInput-token.write" - } - }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/OAuth2.TokenIntrospectionInput-token.write" + "$ref": "#/components/schemas/MaintenanceExport.ConfirmMaintenanceExportInput-maintenance_export.write" } } }, "required": true - }, - "security": [ - { - "bearerAuth": [] - } - ] + } } }, - "/api/oauth2/token/revoke": { - "post": { - "operationId": "revoke_token", + "/api/organizations/{organizationId}/maintenance-exports/{id}/files/{format}": { + "get": { + "operationId": "maintenance_export_file", "tags": [ - "OAuth2" + "Maintenance export" ], "responses": { - "200": { - "description": "Token revoked successfully", - "links": { - "IssueNewToken": { - "operationId": "token", - "description": "Issue a new token after revoking the old one" - } - } + "204": { + "description": "MaintenanceExport resource" }, - "400": { - "description": "Invalid request - missing token parameter", + "403": { + "description": "Forbidden", "content": { "application/ld+json": { "schema": { @@ -30878,11 +32650,8 @@ }, "links": {} }, - "401": { - "description": "Invalid client credentials" - }, - "403": { - "description": "Forbidden", + "404": { + "description": "Not found", "content": { "application/ld+json": { "schema": { @@ -30901,166 +32670,70 @@ } }, "links": {} - }, - "422": { - "description": "An error occurred", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" - } - } - }, - "links": {} - }, - "429": { - "description": "Too many revocation requests" } }, - "summary": "Revoke Token", - "description": "Revoke an access token or refresh token as defined in RFC 7009.", - "parameters": [], - "requestBody": { - "description": "The new OAuth2 resource", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/OAuth2.TokenRevocationInput" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/OAuth2.TokenRevocationInput" - } + "summary": "Download the exact preserved CSV or JSON bytes", + "description": "Download the exact preserved CSV or JSON bytes", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "MaintenanceExport identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" }, - "application/x-www-form-urlencoded": { - "schema": { - "$ref": "#/components/schemas/OAuth2.TokenRevocationInput" - } - } + "style": "simple", + "explode": false }, - "required": true - }, - "security": [ { - "bearerAuth": [] - } - ] - } - }, - "/api/oauth2/userinfo": { - "get": { - "operationId": "userinfo", - "tags": [ - "OAuth2" - ], - "responses": { - "200": { - "description": "User information retrieved successfully", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/OAuth2.UserInfoOutput.jsonld-token.read" - } - } + "name": "id", + "in": "path", + "description": "MaintenanceExport identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" }, - "links": { - "CheckConsent": { - "operationId": "check_consent", - "description": "Check user consent status for a client" - } - } - }, - "401": { - "description": "Missing or invalid access token" - }, - "403": { - "description": "Access token lacks required scopes" + "style": "simple", + "explode": false }, - "404": { - "description": "Not found", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "links": {} - } - }, - "summary": "Get User Information", - "description": "Retrieve claims about the authenticated user as defined by OpenID Connect Core 1.0.", - "parameters": [], - "security": [ { - "bearerAuth": [] + "name": "format", + "in": "path", + "description": "MaintenanceExport identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false } ] } }, - "/api/otp/challenges": { + "/api/maintenance/campaigns": { "post": { - "operationId": "createChallenge", + "operationId": "api_maintenancecampaigns_post", "tags": [ - "OTP" + "MaintenanceCampaign" ], "responses": { "201": { - "description": "OTP challenge created successfully", + "description": "MaintenanceCampaign resource created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Challenge.ChallengeOutput.jsonld" + "$ref": "#/components/schemas/MaintenanceCampaign.GenerateInspectionCampaignOutput.jsonld" } } }, - "links": { - "GetChallengeStatus": { - "operationId": "getChallengeStatus", - "description": "Check the challenge status", - "parameters": { - "token": "$response.body#/token" - } - }, - "VerifyChallenge": { - "operationId": "verifyChallenge", - "description": "Verify the OTP code", - "parameters": { - "token": "$response.body#/token" - } - }, - "ResendChallenge": { - "operationId": "resendChallenge", - "description": "Resend the OTP if canResendIn is 0", - "parameters": { - "token": "$response.body#/token" - } - } - } + "links": {} }, "400": { - "description": "Invalid request parameters", + "description": "Invalid input", "content": { "application/ld+json": { "schema": { @@ -31121,91 +32794,55 @@ } }, "links": {} - }, - "429": { - "description": "Rate limit exceeded" } }, - "summary": "Create OTP challenge", - "description": "Creates a new OTP challenge and sends the code via the specified channel. Requires otp_challenges.create permission.", + "summary": "Creates a MaintenanceCampaign resource.", + "description": "Creates a MaintenanceCampaign resource.", "parameters": [], "requestBody": { - "description": "The new Challenge resource", + "description": "The new MaintenanceCampaign resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Challenge.CreateChallengeInput" + "$ref": "#/components/schemas/MaintenanceCampaign.GenerateInspectionCampaignInput" } } }, "required": true - }, - "security": [ - { - "bearerAuth": [] - } - ] + } } }, - "/api/otp/challenges/{token}": { + "/api/organizations/{organizationId}/interventions/{id}/costs": { "get": { - "operationId": "getChallengeStatus", + "operationId": "maintenance_cost_read", "tags": [ - "OTP" + "MaintenanceCost" ], "responses": { "200": { - "description": "Challenge status retrieved", + "description": "Private internal costs", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Challenge.ChallengeOutput.jsonld" + "$ref": "#/components/schemas/MaintenanceCost.MaintenanceCostOutput.jsonld-maintenance_cost.read" } } - }, - "links": { - "VerifyChallenge": { - "operationId": "verifyChallenge", - "description": "Verify the OTP code if status is pending" - }, - "ResendChallenge": { - "operationId": "resendChallenge", - "description": "Resend the OTP if canResendIn is 0" - } } }, "403": { - "description": "Forbidden", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "links": {} + "description": "Financial read permission missing" }, "404": { - "description": "Challenge not found" + "description": "Intervention unavailable" } }, - "summary": "Get challenge status", - "description": "Returns the current status of an OTP challenge. Requires otp_challenges.read permission.", + "summary": "Read private current and frozen intervention costs", + "description": "Read private current and frozen intervention costs", "parameters": [ { - "name": "token", + "name": "organizationId", "in": "path", - "description": "Challenge identifier", + "description": "MaintenanceCost identifier", "required": true, "deprecated": false, "schema": { @@ -31213,28 +32850,35 @@ }, "style": "simple", "explode": false - } - ], - "security": [ + }, { - "bearerAuth": [] + "name": "id", + "in": "path", + "description": "MaintenanceCost identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false } ] } }, - "/api/otp/challenges/{token}/resend": { + "/api/organizations/{organizationId}/interventions/{id}/costs/expenses": { "post": { - "operationId": "resendChallenge", + "operationId": "maintenance_cost_expense", "tags": [ - "OTP" + "MaintenanceCost" ], "responses": { - "200": { - "description": "OTP resent successfully", + "201": { + "description": "Expense retained or exact replay", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Challenge.ChallengeOutput.jsonld" + "$ref": "#/components/schemas/MaintenanceCost.MaintenanceCostOutput.jsonld-maintenance_cost.read" } } }, @@ -31282,11 +32926,11 @@ }, "links": {} }, - "404": { - "description": "Challenge not found" + "409": { + "description": "Stable declaration identity conflict" }, "422": { - "description": "An error occurred", + "description": "Invalid exact amount or source", "content": { "application/ld+json": { "schema": { @@ -31305,18 +32949,15 @@ } }, "links": {} - }, - "429": { - "description": "Resend cooldown not yet elapsed" } }, - "summary": "Resend OTP", - "description": "Resends the OTP code. Subject to cooldown period. Requires otp_challenges.resend permission.", + "summary": "Append a motivated external expense or correction", + "description": "Append a motivated external expense or correction", "parameters": [ { - "name": "token", + "name": "organizationId", "in": "path", - "description": "Challenge identifier", + "description": "MaintenanceCost identifier", "required": true, "deprecated": false, "schema": { @@ -31324,35 +32965,53 @@ }, "style": "simple", "explode": false - } - ], - "security": [ + }, { - "bearerAuth": [] + "name": "id", + "in": "path", + "description": "MaintenanceCost identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false } - ] + ], + "requestBody": { + "description": "The new MaintenanceCost resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/MaintenanceCost.CreateMaintenanceExpenseInput-maintenance_cost.write" + } + } + }, + "required": true + } } }, - "/api/otp/challenges/{token}/verify": { - "post": { - "operationId": "verifyChallenge", + "/api/organizations/{organizationId}/interventions/{id}/costs/planning": { + "patch": { + "operationId": "maintenance_cost_planning", "tags": [ - "OTP" + "MaintenanceCost" ], "responses": { "200": { - "description": "OTP verified successfully", + "description": "Financial preparation updated", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Challenge.VerifyOtpOutput.jsonld" + "$ref": "#/components/schemas/MaintenanceCost.MaintenanceCostOutput.jsonld-maintenance_cost.read" } } }, "links": {} }, "400": { - "description": "Invalid OTP code", + "description": "Invalid input", "content": { "application/ld+json": { "schema": { @@ -31394,10 +33053,31 @@ "links": {} }, "404": { - "description": "Challenge not found" + "description": "Not found", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} }, - "410": { - "description": "Challenge expired or max attempts reached" + "409": { + "description": "Preparation already closed" + }, + "412": { + "description": "Stale planning revision" }, "422": { "description": "An error occurred", @@ -31420,17 +33100,42 @@ }, "links": {} }, - "429": { - "description": "Too many OTP verification attempts" + "428": { + "description": "Planning revision required" } }, - "summary": "Verify challenge", - "description": "Verifies the OTP code for a challenge. Requires otp_challenges.verify permission.", + "summary": "Prepare an internal budget and estimated resources", + "description": "Prepare an internal budget and estimated resources", "parameters": [ { - "name": "token", + "name": "If-Match", + "in": "header", + "description": "", + "required": true, + "deprecated": false, + "schema": { + "type": "string", + "example": "\"revision-0\"" + }, + "style": "simple", + "explode": false + }, + { + "name": "organizationId", "in": "path", - "description": "Challenge identifier", + "description": "MaintenanceCost identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "id", + "in": "path", + "description": "MaintenanceCost identifier", "required": true, "deprecated": false, "schema": { @@ -31441,44 +33146,33 @@ } ], "requestBody": { - "description": "The new Challenge resource", + "description": "The updated MaintenanceCost resource", "content": { - "application/ld+json": { + "application/merge-patch+json": { "schema": { - "$ref": "#/components/schemas/Challenge.VerifyOtpInput" + "$ref": "#/components/schemas/MaintenanceCost.WriteCostPlanningInput-maintenance_cost.write.jsonMergePatch" } } }, "required": true - }, - "security": [ - { - "bearerAuth": [] - } - ] + } } }, - "/api/otp/channels": { + "/api/organizations/{organizationId}/maintenance-cost/currency": { "get": { - "operationId": "listChannels", + "operationId": "maintenance_cost_currency_read", "tags": [ - "OTP" + "MaintenanceCost" ], "responses": { "200": { - "description": "List of available OTP channels", + "description": "MaintenanceCostCurrency resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/OtpConfig.Config.ChannelOutput.jsonld" + "$ref": "#/components/schemas/MaintenanceCostCurrency.MaintenanceCurrencyOutput.jsonld-maintenance_cost_currency.read" } } - }, - "links": { - "CreateChallenge": { - "operationId": "createChallenge", - "description": "Create a new OTP challenge using one of these channels" - } } }, "403": { @@ -31524,62 +33218,42 @@ "links": {} } }, - "summary": "List OTP channels", - "description": "Returns all available OTP delivery channels. Requires otp_config.read permission.", - "parameters": [], - "security": [ + "summary": "Read the private organization cost currency", + "description": "Read the private organization cost currency", + "parameters": [ { - "bearerAuth": [] + "name": "organizationId", + "in": "path", + "description": "MaintenanceCostCurrency identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false } ] - } - }, - "/api/otp/purposes": { - "get": { - "operationId": "listPurposes", + }, + "patch": { + "operationId": "maintenance_cost_currency_configure", "tags": [ - "OTP" + "MaintenanceCost" ], "responses": { "200": { - "description": "List of available OTP purposes", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/OtpConfig.PurposeOutput.jsonld" - } - } - }, - "links": { - "CreateChallenge": { - "operationId": "createChallenge", - "description": "Create a new OTP challenge using one of these purposes" - } - } - }, - "403": { - "description": "Forbidden", + "description": "MaintenanceCostCurrency resource updated", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/MaintenanceCostCurrency.MaintenanceCurrencyOutput.jsonld-maintenance_cost_currency.read" } } }, "links": {} }, - "404": { - "description": "Not found", + "400": { + "description": "Invalid input", "content": { "application/ld+json": { "schema": { @@ -31598,38 +33272,9 @@ } }, "links": {} - } - }, - "summary": "List OTP purposes", - "description": "Returns all available OTP purposes with their default TTL and max attempts configuration. Requires otp_config.read permission.", - "parameters": [], - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/api/otp/totp/confirm": { - "post": { - "operationId": "confirmTotp", - "tags": [ - "OTP" - ], - "responses": { - "200": { - "description": "TOTP activated successfully", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Totp.ConfirmTotpOutput.jsonld" - } - } - }, - "links": {} }, - "400": { - "description": "Invalid input", + "403": { + "description": "Forbidden", "content": { "application/ld+json": { "schema": { @@ -31649,8 +33294,8 @@ }, "links": {} }, - "403": { - "description": "Forbidden", + "404": { + "description": "Not found", "content": { "application/ld+json": { "schema": { @@ -31670,14 +33315,8 @@ }, "links": {} }, - "404": { - "description": "No pending TOTP setup found; call setup first" - }, - "409": { - "description": "An active authenticator cannot be replaced by confirming a pending secret" - }, "422": { - "description": "Invalid or expired verification code", + "description": "An error occurred", "content": { "application/ld+json": { "schema": { @@ -31696,70 +33335,240 @@ } }, "links": {} - }, - "429": { - "description": "Too many confirmation attempts" } }, - "summary": "Confirm TOTP", - "description": "Verifies a code from the authenticator app against the pending secret and, on success, activates TOTP for the user. An existing active factor cannot be replaced, including when a legacy pending secret exists. Requires otp_totp.confirm permission.", - "parameters": [], + "summary": "Configure cost currency before its first financial fact", + "description": "Configure cost currency before its first financial fact", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "MaintenanceCostCurrency identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], "requestBody": { - "description": "The new Totp resource", + "description": "The updated MaintenanceCostCurrency resource", "content": { - "application/ld+json": { + "application/merge-patch+json": { "schema": { - "$ref": "#/components/schemas/Totp.ConfirmTotpInput" + "$ref": "#/components/schemas/MaintenanceCostCurrency.ConfigureMaintenanceCurrencyInput-maintenance_cost_currency.write.jsonMergePatch" } } }, "required": true - }, - "security": [ - { - "bearerAuth": [] - } - ] + } } }, - "/api/otp/totp/disable": { - "post": { - "operationId": "disableTotp", + "/api/organizations/{organizationId}/maintenance-cost/dossiers": { + "get": { + "operationId": "maintenance_economic_dossiers", "tags": [ - "OTP" + "MaintenanceCost" ], "responses": { "200": { - "description": "TOTP disabled successfully", + "description": "Hydra minimal identity page", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Totp.DisableTotpOutput.jsonld" + "type": "object", + "description": "MaintenanceEconomic.MaintenanceEconomicDossierOutput.jsonld-maintenance_economic.read collection.", + "allOf": [ + { + "$ref": "#/components/schemas/HydraCollectionBaseSchema" + }, + { + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MaintenanceEconomic.MaintenanceEconomicDossierOutput.jsonld-maintenance_economic.read" + } + } + } + } + ] } } - }, - "links": {} + } }, - "400": { - "description": "Invalid input", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { + "403": { + "description": "Dedicated financial read missing" + }, + "404": { + "description": "Organization outside scope" + } + }, + "summary": "Browse minimal private finance dossier identities", + "description": "Browse minimal private finance dossier identities", + "parameters": [ + { + "name": "search", + "in": "query", + "description": "", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "maxLength": 160 + }, + "style": "form", + "explode": true + }, + { + "name": "from", + "in": "query", + "description": "", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "date" + }, + "style": "form", + "explode": true + }, + { + "name": "to", + "in": "query", + "description": "", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "date" + }, + "style": "form", + "explode": true + }, + { + "name": "siteId", + "in": "query", + "description": "", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "uuid" + }, + "style": "form", + "explode": true + }, + { + "name": "customerId", + "in": "query", + "description": "", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "uuid" + }, + "style": "form", + "explode": true + }, + { + "name": "equipmentId", + "in": "query", + "description": "", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "uuid" + }, + "style": "form", + "explode": true + }, + { + "name": "page", + "in": "query", + "description": "", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "minimum": 1 + }, + "style": "form", + "explode": true + }, + { + "name": "itemsPerPage", + "in": "query", + "description": "", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100 + }, + "style": "form", + "explode": true + }, + { + "name": "organizationId", + "in": "path", + "description": "MaintenanceEconomic identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ] + } + }, + "/api/organizations/{organizationId}/maintenance-cost/rates": { + "get": { + "operationId": "maintenance_cost_rates_list", + "tags": [ + "MaintenanceCost" + ], + "responses": { + "200": { + "description": "MaintenanceCostRate collection", + "content": { + "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Error" + "type": "object", + "description": "MaintenanceCostRate.MaintenanceRateOutput.jsonld-maintenance_cost_rate.read collection.", + "allOf": [ + { + "$ref": "#/components/schemas/HydraCollectionBaseSchema" + }, + { + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MaintenanceCostRate.MaintenanceRateOutput.jsonld-maintenance_cost_rate.read" + } + } + } + } + ] } } - }, - "links": {} + } }, "403": { "description": "Forbidden", @@ -31781,78 +33590,82 @@ } }, "links": {} + } + }, + "summary": "List private exact hourly rates", + "description": "List private exact hourly rates", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "MaintenanceCostRate identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false }, - "404": { - "description": "TOTP is not currently enabled for the user" - }, - "422": { - "description": "Invalid verification code", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" - } - } + { + "name": "page", + "in": "query", + "description": "The collection page number", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 1 }, - "links": {} + "style": "form", + "explode": true }, - "429": { - "description": "Too many disable attempts" - } - }, - "summary": "Disable TOTP", - "description": "Disables TOTP for the authenticated user. Requires a valid current TOTP code as proof of possession. Requires otp_totp.disable permission.", - "parameters": [], - "requestBody": { - "description": "The new Totp resource", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Totp.DisableTotpInput" - } - } + { + "name": "itemsPerPage", + "in": "query", + "description": "The number of items per page", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 30, + "minimum": 0, + "maximum": 100 + }, + "style": "form", + "explode": true }, - "required": true - }, - "security": [ { - "bearerAuth": [] + "name": "memberId", + "in": "query", + "description": "MaintenanceCostRate memberId", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "uuid" + }, + "style": "form", + "explode": true } ] - } - }, - "/api/otp/totp/setup": { + }, "post": { - "operationId": "setupTotp", + "operationId": "maintenance_cost_rates_create", "tags": [ - "OTP" + "MaintenanceCost" ], "responses": { "201": { - "description": "TOTP setup data returned", + "description": "MaintenanceCostRate resource created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Totp.SetupTotpOutput.jsonld" + "$ref": "#/components/schemas/MaintenanceCostRate.MaintenanceRateOutput.jsonld-maintenance_cost_rate.read" } } }, - "links": { - "ConfirmTotp": { - "operationId": "confirmTotp", - "description": "Confirm the pending secret with a code from the authenticator app" - } - } + "links": {} }, "400": { "description": "Invalid input", @@ -31875,11 +33688,8 @@ }, "links": {} }, - "401": { - "description": "User must be authenticated" - }, "403": { - "description": "Authenticator enrollment is unavailable for this account", + "description": "Forbidden", "content": { "application/ld+json": { "schema": { @@ -31896,10 +33706,8 @@ "$ref": "#/components/schemas/Error" } } - } - }, - "409": { - "description": "An active authenticator must first be disabled with its current code" + }, + "links": {} }, "422": { "description": "An error occurred", @@ -31923,104 +33731,228 @@ "links": {} } }, - "summary": "Setup TOTP", - "description": "Generates a new TOTP secret and stores it server-side as a PENDING enrollment for the authenticated user. Returns the secret and a QR code URI for authenticator app setup. Calling this again replaces the pending secret during initial enrollment. Enrollment requires the account to remain active when its serialized mutation starts. An active factor must first be disabled with its current code; setup refuses replacement. Requires otp_totp.setup permission.", - "parameters": [], - "security": [ + "summary": "Append an effective hourly rate using a stable client identity", + "description": "Append an effective hourly rate using a stable client identity", + "parameters": [ { - "bearerAuth": [] + "name": "organizationId", + "in": "path", + "description": "MaintenanceCostRate identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false } - ] + ], + "requestBody": { + "description": "The new MaintenanceCostRate resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/MaintenanceCostRate.CreateMaintenanceRateInput-maintenance_cost_rate.write" + } + } + }, + "required": true + } } }, - "/api/onboarding/organization": { + "/api/organizations/{organizationId}/maintenance-cost/reports": { "get": { - "operationId": "getOrganizationOnboarding", + "operationId": "maintenance_economic_report", "tags": [ - "Onboarding" + "MaintenanceCost" ], "responses": { "200": { - "description": "Onboarding flow retrieved", + "description": "Exact full-scope totals and row page", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/OrganizationOnboarding.OrganizationOnboardingOutput.jsonld-onboarding.read" + "$ref": "#/components/schemas/MaintenanceEconomic.MaintenanceEconomicReportOutput.jsonld-maintenance_economic.read" } } } }, - "401": { - "description": "Authentication required" - }, "403": { - "description": "Forbidden", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "links": {} + "description": "Dedicated financial read missing" }, "404": { - "description": "Not found", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "links": {} + "description": "Organization outside scope" + }, + "422": { + "description": "Invalid or excessive window; narrow filters" } }, - "summary": "Get organization onboarding flow", - "description": "Returns persisted onboarding progression for organization setup and exposes actionable endpoints for each step.", - "parameters": [] + "summary": "Read exact private maintenance economics with reconciled allocations", + "description": "Read exact private maintenance economics with reconciled allocations", + "parameters": [ + { + "name": "from", + "in": "query", + "description": "", + "required": true, + "deprecated": false, + "schema": { + "type": "string", + "format": "date" + }, + "style": "form", + "explode": true + }, + { + "name": "to", + "in": "query", + "description": "", + "required": true, + "deprecated": false, + "schema": { + "type": "string", + "format": "date" + }, + "style": "form", + "explode": true + }, + { + "name": "groupBy", + "in": "query", + "description": "", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "enum": [ + "equipment", + "site", + "customer" + ] + }, + "style": "form", + "explode": true + }, + { + "name": "siteId", + "in": "query", + "description": "", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "uuid" + }, + "style": "form", + "explode": true + }, + { + "name": "customerId", + "in": "query", + "description": "", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "uuid" + }, + "style": "form", + "explode": true + }, + { + "name": "equipmentId", + "in": "query", + "description": "", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "uuid" + }, + "style": "form", + "explode": true + }, + { + "name": "page", + "in": "query", + "description": "", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "minimum": 1 + }, + "style": "form", + "explode": true + }, + { + "name": "itemsPerPage", + "in": "query", + "description": "", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100 + }, + "style": "form", + "explode": true + }, + { + "name": "organizationId", + "in": "path", + "description": "MaintenanceEconomic identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ] } }, - "/api/onboarding/organization/dismiss": { - "post": { - "operationId": "dismissOrganizationOnboarding", + "/api/maintenance/schedules": { + "get": { + "operationId": "api_maintenanceschedules_get_collection", "tags": [ - "Onboarding" + "MaintenanceSchedule" ], "responses": { "200": { - "description": "Onboarding activation dismissed", + "description": "MaintenanceSchedule collection", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/OrganizationOnboarding.OrganizationOnboardingOutput.jsonld-onboarding.read" + "type": "object", + "description": "MaintenanceSchedule.MaintenanceScheduleOutput.jsonld collection.", + "allOf": [ + { + "$ref": "#/components/schemas/HydraCollectionBaseSchema" + }, + { + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MaintenanceSchedule.MaintenanceScheduleOutput.jsonld" + } + } + } + } + ] } } - }, - "links": {} + } }, - "400": { - "description": "Invalid input", + "403": { + "description": "Forbidden", "content": { "application/ld+json": { "schema": { @@ -32039,10 +33971,119 @@ } }, "links": {} - }, - "401": { - "description": "Authentication required" - }, + } + }, + "summary": "Retrieves the collection of MaintenanceSchedule resources.", + "description": "Retrieves the collection of MaintenanceSchedule resources.", + "parameters": [ + { + "name": "page", + "in": "query", + "description": "The collection page number", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 1 + }, + "style": "form", + "explode": true + }, + { + "name": "itemsPerPage", + "in": "query", + "description": "The number of items per page", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 30, + "minimum": 0, + "maximum": 100 + }, + "style": "form", + "explode": true + }, + { + "name": "organization", + "in": "query", + "description": "Organization IRI.", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "form", + "explode": true + }, + { + "name": "facility", + "in": "query", + "description": "Facility IRI filter.", + "required": false, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "form", + "explode": true + }, + { + "name": "equipmentType", + "in": "query", + "description": "Equipment type filter.", + "required": false, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "form", + "explode": true + }, + { + "name": "dueStatus", + "in": "query", + "description": "Due status filter (unscheduled|up_to_date|due_soon|overdue).", + "required": false, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "form", + "explode": true + }, + { + "name": "dueBefore", + "in": "query", + "description": "ISO-8601 upper bound on the next due date.", + "required": false, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "form", + "explode": true + } + ] + } + }, + "/api/maintenance/schedules/{id}": { + "get": { + "operationId": "api_maintenanceschedules_id_get", + "tags": [ + "MaintenanceSchedule" + ], + "responses": { + "200": { + "description": "MaintenanceSchedule resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/MaintenanceSchedule.MaintenanceScheduleOutput.jsonld" + } + } + } + }, "403": { "description": "Forbidden", "content": { @@ -32064,46 +34105,57 @@ }, "links": {} }, - "422": { - "description": "An error occurred", + "404": { + "description": "Not found", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation.jsonld" + "$ref": "#/components/schemas/Error.jsonld" } }, "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation" + "$ref": "#/components/schemas/Error" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation" + "$ref": "#/components/schemas/Error" } } }, "links": {} } }, - "summary": "Dismiss organization onboarding activation", - "description": "Voluntarily hides the non-blocking activation flow without completing it. Progression is preserved and can be resumed later.", - "parameters": [] - } - }, - "/api/onboarding/organization/resume": { - "post": { - "operationId": "resumeOrganizationOnboarding", + "summary": "Retrieves a MaintenanceSchedule resource.", + "description": "Retrieves a MaintenanceSchedule resource.", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "MaintenanceSchedule identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ] + }, + "patch": { + "operationId": "api_maintenanceschedules_id_patch", "tags": [ - "Onboarding" + "MaintenanceSchedule" ], "responses": { "200": { - "description": "Onboarding activation resumed", + "description": "MaintenanceSchedule resource updated", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/OrganizationOnboarding.OrganizationOnboardingOutput.jsonld-onboarding.read" + "$ref": "#/components/schemas/MaintenanceSchedule.MaintenanceScheduleOutput.jsonld" } } }, @@ -32130,9 +34182,6 @@ }, "links": {} }, - "401": { - "description": "Authentication required" - }, "403": { "description": "Forbidden", "content": { @@ -32154,6 +34203,27 @@ }, "links": {} }, + "404": { + "description": "Not found", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, "422": { "description": "An error occurred", "content": { @@ -32176,24 +34246,48 @@ "links": {} } }, - "summary": "Resume organization onboarding activation", - "description": "Clears a previous dismissal so the activation flow and setup checklist become visible again.", - "parameters": [] + "summary": "Updates the MaintenanceSchedule resource.", + "description": "Updates the MaintenanceSchedule resource.", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "MaintenanceSchedule identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], + "requestBody": { + "description": "The updated MaintenanceSchedule resource", + "content": { + "application/merge-patch+json": { + "schema": { + "$ref": "#/components/schemas/MaintenanceSchedule.UpdateMaintenanceScheduleInput.jsonMergePatch" + } + } + }, + "required": true + } } }, - "/api/onboarding/organization/rollback": { + "/api/media": { "post": { - "operationId": "rollbackOrganizationOnboarding", + "operationId": "api_media_post", "tags": [ - "Onboarding" + "Media" ], "responses": { - "200": { - "description": "Onboarding step rolled back", + "201": { + "description": "Media resource created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/OrganizationOnboarding.OrganizationOnboardingOutput.jsonld-onboarding.read" + "$ref": "#/components/schemas/Media.AttachmentOutput.jsonld" } } }, @@ -32220,9 +34314,6 @@ }, "links": {} }, - "401": { - "description": "Authentication required" - }, "403": { "description": "Forbidden", "content": { @@ -32244,9 +34335,6 @@ }, "links": {} }, - "409": { - "description": "No rollback action available" - }, "422": { "description": "An error occurred", "content": { @@ -32269,31 +34357,51 @@ "links": {} } }, - "summary": "Rollback last organization onboarding step", - "description": "Rolls back the last rollbackable onboarding step for the authenticated user.", + "summary": "Creates a Media resource.", + "description": "Creates a Media resource.", "parameters": [] } }, - "/api/onboarding/organization/setup-operations": { - "post": { - "operationId": "prepareOrganizationSetup", + "/api/media/{id}": { + "get": { + "operationId": "api_media_id_get", "tags": [ - "Onboarding" + "Media" ], "responses": { "200": { - "description": "Batch prepared or already prepared", + "description": "Media resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/OrganizationOnboarding.OrganizationOnboardingOutput.jsonld-onboarding.read" + "$ref": "#/components/schemas/Media.AttachmentOutput.jsonld" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" } } }, "links": {} }, - "400": { - "description": "Invalid input", + "404": { + "description": "Not found", "content": { "application/ld+json": { "schema": { @@ -32312,9 +34420,33 @@ } }, "links": {} - }, - "401": { - "description": "Authentication required" + } + }, + "summary": "Retrieves a Media resource.", + "description": "Retrieves a Media resource.", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Media identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ] + }, + "delete": { + "operationId": "api_media_id_delete", + "tags": [ + "Media" + ], + "responses": { + "204": { + "description": "Media resource deleted" }, "403": { "description": "Forbidden", @@ -32337,60 +34469,162 @@ }, "links": {} }, - "409": { - "description": "Session, step, item or payload conflict" - }, - "422": { - "description": "An error occurred", + "404": { + "description": "Not found", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation.jsonld" + "$ref": "#/components/schemas/Error.jsonld" } }, "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation" + "$ref": "#/components/schemas/Error" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation" + "$ref": "#/components/schemas/Error" } } }, "links": {} } }, - "summary": "Prepare a durable setup batch", - "description": "Persists up to five item inputs for the current creator step. Repeating a key requires identical input. Owner resource POSTs consume the returned session and item keys atomically.", - "parameters": [], - "requestBody": { - "description": "The new OrganizationOnboarding resource", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/OrganizationOnboarding.PrepareOrganizationSetupInput-onboarding.write" + "summary": "Removes the Media resource.", + "description": "Removes the Media resource.", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Media identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ] + } + }, + "/api/conversations/{conversationId}/messages": { + "get": { + "operationId": "api_conversations_conversationIdmessages_get_collection", + "tags": [ + "Message" + ], + "responses": { + "200": { + "description": "Message collection", + "content": { + "application/ld+json": { + "schema": { + "type": "object", + "description": "Message.MessageOutput.jsonld collection.", + "allOf": [ + { + "$ref": "#/components/schemas/HydraCollectionBaseSchema" + }, + { + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Message.MessageOutput.jsonld" + } + } + } + } + ] + } } } }, - "required": true - } - } - }, - "/api/onboarding/organization/start": { + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + } + }, + "summary": "Retrieves the collection of Message resources.", + "description": "Retrieves the collection of Message resources.", + "parameters": [ + { + "name": "conversationId", + "in": "path", + "description": "Message identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "page", + "in": "query", + "description": "The collection page number", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 1 + }, + "style": "form", + "explode": true + }, + { + "name": "itemsPerPage", + "in": "query", + "description": "The number of items per page", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 30, + "minimum": 0, + "maximum": 100 + }, + "style": "form", + "explode": true + } + ] + }, "post": { - "operationId": "startOrganizationOnboarding", + "operationId": "api_conversations_conversationIdmessages_post", "tags": [ - "Onboarding" + "Message" ], "responses": { - "200": { - "description": "Onboarding flow started", + "201": { + "description": "Message resource created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/OrganizationOnboarding.OrganizationOnboardingOutput.jsonld-onboarding.read" + "$ref": "#/components/schemas/Message.MessageOutput.jsonld" } } }, @@ -32417,9 +34651,6 @@ }, "links": {} }, - "401": { - "description": "Authentication required" - }, "403": { "description": "Forbidden", "content": { @@ -32463,15 +34694,28 @@ "links": {} } }, - "summary": "Start organization onboarding flow", - "description": "Starts or resets the persisted organization onboarding session for the authenticated user.", - "parameters": [], + "summary": "Creates a Message resource.", + "description": "Creates a Message resource.", + "parameters": [ + { + "name": "conversationId", + "in": "path", + "description": "Message identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], "requestBody": { - "description": "The new OrganizationOnboarding resource", + "description": "The new Message resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/OrganizationOnboarding.StartOrganizationOnboardingInput-onboarding.write" + "$ref": "#/components/schemas/Message.PostMessageInput" } } }, @@ -32479,26 +34723,26 @@ } } }, - "/api/onboarding/organization/steps/{stepKey}/execute": { - "post": { - "operationId": "executeOrganizationOnboardingStep", + "/api/conversations/{conversationId}/messages/{clientId}": { + "put": { + "operationId": "put_message", "tags": [ - "Onboarding" + "Message" ], "responses": { - "200": { - "description": "Onboarding step executed", + "201": { + "description": "Message created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/OrganizationOnboarding.OrganizationOnboardingOutput.jsonld-onboarding.read" + "$ref": "#/components/schemas/Message.MessageOutput.jsonld" } } }, "links": {} }, "400": { - "description": "Invalid step payload", + "description": "Invalid input", "content": { "application/ld+json": { "schema": { @@ -32518,9 +34762,6 @@ }, "links": {} }, - "401": { - "description": "Authentication required" - }, "403": { "description": "Forbidden", "content": { @@ -32542,125 +34783,32 @@ }, "links": {} }, - "409": { - "description": "Step not available in current flow state" - }, - "422": { - "description": "An error occurred", + "404": { + "description": "Not found", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation.jsonld" + "$ref": "#/components/schemas/Error.jsonld" } }, "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation" + "$ref": "#/components/schemas/Error" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation" - } - } - }, - "links": {} - } - }, - "summary": "Execute organization onboarding step", - "description": "Executes the requested onboarding step and updates persisted flow state.", - "parameters": [ - { - "name": "stepKey", - "in": "path", - "description": "OrganizationOnboarding identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - } - ], - "requestBody": { - "description": "The new OrganizationOnboarding resource", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/OrganizationOnboarding.ExecuteOrganizationOnboardingStepInput-onboarding.write" - } - } - }, - "required": true - } - } - }, - "/api/onboarding/organization/steps/{stepKey}/skip": { - "post": { - "operationId": "skipOrganizationOnboardingStep", - "tags": [ - "Onboarding" - ], - "responses": { - "200": { - "description": "Onboarding step skipped", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/OrganizationOnboarding.OrganizationOnboardingOutput.jsonld-onboarding.read" - } - } - }, - "links": {} - }, - "400": { - "description": "Invalid or required step", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "links": {} - }, - "401": { - "description": "Authentication required" - }, - "403": { - "description": "Forbidden", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Error" } } }, "links": {} }, "409": { - "description": "Step not available to skip in current flow state" + "description": "This client identifier was already used" + }, + "412": { + "description": "If-None-Match must be *" }, "422": { "description": "An error occurred", @@ -32682,15 +34830,30 @@ } }, "links": {} + }, + "428": { + "description": "If-None-Match: * is required" } }, - "summary": "Skip organization onboarding step", - "description": "Voluntarily skips a non-required onboarding step, allowing the flow to advance to completion without executing it.", + "summary": "Replaces the Message resource.", + "description": "Replaces the Message resource.", "parameters": [ { - "name": "stepKey", + "name": "conversationId", "in": "path", - "description": "OrganizationOnboarding identifier", + "description": "Message identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "clientId", + "in": "path", + "description": "Message identifier", "required": true, "deprecated": false, "schema": { @@ -32699,23 +34862,34 @@ "style": "simple", "explode": false } - ] + ], + "requestBody": { + "description": "The updated Message resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Message.PostMessageInput" + } + } + }, + "required": true + } } }, - "/api/organizations": { + "/api/conversations/{conversationId}/pinned-messages": { "get": { - "operationId": "listUserOrganizations", + "operationId": "list_pinned_messages", "tags": [ - "Organization" + "Message" ], "responses": { "200": { - "description": "Organization collection", + "description": "Message collection", "content": { "application/ld+json": { "schema": { "type": "object", - "description": "Organization.OrganizationOutput.jsonld-Organization.read collection.", + "description": "Message.MessageOutput.jsonld collection.", "allOf": [ { "$ref": "#/components/schemas/HydraCollectionBaseSchema" @@ -32729,7 +34903,7 @@ "member": { "type": "array", "items": { - "$ref": "#/components/schemas/Organization.OrganizationOutput.jsonld-Organization.read" + "$ref": "#/components/schemas/Message.MessageOutput.jsonld" } } } @@ -32761,9 +34935,21 @@ "links": {} } }, - "summary": "List user Organizations", - "description": "Lists Organizations where the authenticated user is a member.", + "summary": "Retrieves the collection of Message resources.", + "description": "Retrieves the collection of Message resources.", "parameters": [ + { + "name": "conversationId", + "in": "path", + "description": "Message identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, { "name": "page", "in": "query", @@ -32791,38 +34977,92 @@ }, "style": "form", "explode": true + } + ] + } + }, + "/api/messages/{id}": { + "delete": { + "operationId": "api_messages_id_delete", + "tags": [ + "Message" + ], + "responses": { + "204": { + "description": "Message resource deleted" + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} }, + "404": { + "description": "Not found", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + } + }, + "summary": "Removes the Message resource.", + "description": "Removes the Message resource.", + "parameters": [ { - "name": "status", - "in": "query", - "description": "Filter by organization status.", - "required": false, + "name": "id", + "in": "path", + "description": "Message identifier", + "required": true, "deprecated": false, "schema": { - "type": "string", - "enum": [ - "active", - "suspended", - "archived" - ] + "type": "string" }, - "style": "form", - "explode": true + "style": "simple", + "explode": false } ] }, - "post": { - "operationId": "createOrganization", + "patch": { + "operationId": "api_messages_id_patch", "tags": [ - "Organization" + "Message" ], "responses": { - "201": { - "description": "Organization created", + "200": { + "description": "Message resource updated", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Organization.OrganizationOutput.jsonld-Organization.read" + "$ref": "#/components/schemas/Message.MessageOutput.jsonld" } } }, @@ -32849,9 +35089,6 @@ }, "links": {} }, - "401": { - "description": "Authentication required" - }, "403": { "description": "Forbidden", "content": { @@ -32873,6 +35110,27 @@ }, "links": {} }, + "404": { + "description": "Not found", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, "422": { "description": "An error occurred", "content": { @@ -32895,15 +35153,28 @@ "links": {} } }, - "summary": "Create Organization", - "description": "Creates a Organization and assigns the creator as owner.", - "parameters": [], + "summary": "Updates the Message resource.", + "description": "Updates the Message resource.", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Message identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], "requestBody": { - "description": "The new Organization resource", + "description": "The updated Message resource", "content": { - "application/ld+json": { + "application/merge-patch+json": { "schema": { - "$ref": "#/components/schemas/Organization.CreateOrganizationInput-Organization.write" + "$ref": "#/components/schemas/Message.EditMessageInput.jsonMergePatch" } } }, @@ -32911,48 +35182,26 @@ } } }, - "/api/organizations/legal-types": { - "get": { - "operationId": "listOrganizationLegalTypes", + "/api/messages/{id}/pin": { + "post": { + "operationId": "pin_message", "tags": [ - "Organization" + "Message" ], "responses": { "200": { - "description": "Organization legal types retrieved", + "description": "Message resource created", "content": { "application/ld+json": { "schema": { - "type": "object", - "description": "OrganizationLegalType.OrganizationLegalTypeOptionOutput.jsonld-Organization.read collection.", - "allOf": [ - { - "$ref": "#/components/schemas/HydraCollectionBaseSchema" - }, - { - "type": "object", - "required": [ - "member" - ], - "properties": { - "member": { - "type": "array", - "items": { - "$ref": "#/components/schemas/OrganizationLegalType.OrganizationLegalTypeOptionOutput.jsonld-Organization.read" - } - } - } - } - ] + "$ref": "#/components/schemas/Message.MessageOutput.jsonld" } } - } - }, - "401": { - "description": "Authentication required" + }, + "links": {} }, - "403": { - "description": "Forbidden", + "400": { + "description": "Invalid input", "content": { "application/ld+json": { "schema": { @@ -32971,43 +35220,6 @@ } }, "links": {} - } - }, - "summary": "List organization legal types", - "description": "Returns organization legal entity type values for the Legal profile settings tab select.", - "parameters": [ - { - "name": "page", - "in": "query", - "description": "The collection page number", - "required": false, - "deprecated": false, - "schema": { - "type": "integer", - "default": 1 - }, - "style": "form", - "explode": true - } - ] - } - }, - "/api/organizations/{id}": { - "get": { - "operationId": "getOrganization", - "tags": [ - "Organization" - ], - "responses": { - "200": { - "description": "Organization resource", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Organization.OrganizationOutput.jsonld-Organization.read" - } - } - } }, "403": { "description": "Forbidden", @@ -33030,35 +35242,35 @@ }, "links": {} }, - "404": { - "description": "Not found", + "422": { + "description": "An error occurred", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Error.jsonld" + "$ref": "#/components/schemas/ConstraintViolation.jsonld" } }, "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/ConstraintViolation" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/ConstraintViolation" } } }, "links": {} } }, - "summary": "Get Organization", - "description": "Returns Organization details if the user has Organization.read permission in that Organization.", + "summary": "Creates a Message resource.", + "description": "Creates a Message resource.", "parameters": [ { "name": "id", "in": "path", - "description": "Organization identifier", + "description": "Message identifier", "required": true, "deprecated": false, "schema": { @@ -33070,31 +35282,64 @@ ] }, "delete": { - "operationId": "deleteOrganization", + "operationId": "unpin_message", "tags": [ - "Organization" + "Message" ], "responses": { "204": { - "description": "Organization archived" + "description": "Message resource deleted" }, "403": { - "description": "Insufficient permissions" + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} }, "404": { - "description": "Organization not found" - }, - "422": { - "description": "Missing or mismatched slug confirmation" + "description": "Not found", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} } }, - "summary": "Archive Organization", - "description": "Archives the organization (reversible soft delete — NOT a permanent removal): it is hidden from the default listing and its owned data (facilities, equipment, inspections, interventions) is preserved rather than orphaned, and can be restored through the settings PATCH (isActive: true). Requires the organization.delete permission plus a danger-zone confirmation: the \"slug\" query parameter must exactly match the organization's current slug (case-insensitive, trimmed). A missing or mismatched confirmation is rejected with HTTP 422 and nothing is archived. Idempotent when already archived, provided the confirmation is still correct.", + "summary": "Removes the Message resource.", + "description": "Removes the Message resource.", "parameters": [ { "name": "id", "in": "path", - "description": "Organization identifier", + "description": "Message identifier", "required": true, "deprecated": false, "schema": { @@ -33102,40 +35347,30 @@ }, "style": "simple", "explode": false - }, - { - "name": "slug", - "in": "query", - "description": "Danger-zone confirmation: the organization's current slug, typed by the caller.", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "form", - "explode": true } ] - }, - "patch": { - "operationId": "updateOrganizationSettings", + } + }, + "/api/messages/{id}/reactions": { + "post": { + "operationId": "add_reaction", "tags": [ - "Organization" + "Message" ], "responses": { "200": { - "description": "Organization updated", + "description": "Message resource created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Organization.OrganizationOutput.jsonld-Organization.read" + "$ref": "#/components/schemas/Message.MessageOutput.jsonld" } } }, "links": {} }, "400": { - "description": "Invalid request - validation failed", + "description": "Invalid input", "content": { "application/ld+json": { "schema": { @@ -33156,13 +35391,25 @@ "links": {} }, "403": { - "description": "Insufficient permissions" - }, - "404": { - "description": "Organization not found" - }, - "409": { - "description": "Slug already in use" + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} }, "422": { "description": "An error occurred", @@ -33186,13 +35433,13 @@ "links": {} } }, - "summary": "Update Organization settings", - "description": "Updates organization settings — general & branding (name, slug, description, active status) plus the notifications and regional sections. Requires the organization.settings.write permission.", + "summary": "Creates a Message resource.", + "description": "Creates a Message resource.", "parameters": [ { "name": "id", "in": "path", - "description": "Organization identifier", + "description": "Message identifier", "required": true, "deprecated": false, "schema": { @@ -33203,11 +35450,11 @@ } ], "requestBody": { - "description": "The updated Organization resource", + "description": "The new Message resource", "content": { - "application/merge-patch+json": { + "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Organization.UpdateOrganizationSettingsInput-Organization.write.jsonMergePatch" + "$ref": "#/components/schemas/Message.AddReactionInput" } } }, @@ -33215,26 +35462,39 @@ } } }, - "/api/organizations/{id}/plan": { - "patch": { - "operationId": "changeOrganizationPlan", + "/api/messages/{id}/reactions/{emoji}": { + "delete": { + "operationId": "remove_reaction", "tags": [ - "Organization" + "Message" ], "responses": { - "200": { - "description": "Organization plan updated", + "204": { + "description": "Message resource deleted" + }, + "403": { + "description": "Forbidden", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Organization.OrganizationOutput.jsonld-Organization.read" + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" } } }, "links": {} }, - "400": { - "description": "Invalid request - plan not selectable", + "404": { + "description": "Not found", "content": { "application/ld+json": { "schema": { @@ -33253,45 +35513,104 @@ } }, "links": {} + } + }, + "summary": "Removes the Message resource.", + "description": "Removes the Message resource.", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Message identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false }, - "403": { - "description": "Insufficient permissions" - }, - "404": { - "description": "Organization or plan not found" - }, - "409": { - "description": "Paid plan requires the billing checkout flow, or current usage exceeds the selected plan limits (confirm with acknowledgeOveruse)" + { + "name": "emoji", + "in": "path", + "description": "Message identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ] + } + }, + "/api/messages/{id}/replies": { + "get": { + "operationId": "list_replies", + "tags": [ + "Message" + ], + "responses": { + "200": { + "description": "Message collection", + "content": { + "application/ld+json": { + "schema": { + "type": "object", + "description": "Message.MessageOutput.jsonld collection.", + "allOf": [ + { + "$ref": "#/components/schemas/HydraCollectionBaseSchema" + }, + { + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Message.MessageOutput.jsonld" + } + } + } + } + ] + } + } + } }, - "422": { - "description": "An error occurred", + "403": { + "description": "Forbidden", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation.jsonld" + "$ref": "#/components/schemas/Error.jsonld" } }, "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation" + "$ref": "#/components/schemas/Error" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation" + "$ref": "#/components/schemas/Error" } } }, "links": {} } }, - "summary": "Change Organization plan", - "description": "Assigns a subscription plan to the organization. Self-service — requires the organization.settings.write permission. When the target plan's caps sit below the current usage, the change is refused with HTTP 409 listing the exceeded resources; re-submit with acknowledgeOveruse: true to confirm the downgrade.", + "summary": "Retrieves the collection of Message resources.", + "description": "Retrieves the collection of Message resources.", "parameters": [ { "name": "id", "in": "path", - "description": "Organization identifier", + "description": "Message identifier", "required": true, "deprecated": false, "schema": { @@ -33299,34 +35618,49 @@ }, "style": "simple", "explode": false - } - ], - "requestBody": { - "description": "The updated Organization resource", - "content": { - "application/merge-patch+json": { - "schema": { - "$ref": "#/components/schemas/Organization.ChangeOrganizationPlanInput-Organization.write.jsonMergePatch" - } - } }, - "required": true - } - } - }, - "/api/organizations/{id}/restore": { + { + "name": "page", + "in": "query", + "description": "The collection page number", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 1 + }, + "style": "form", + "explode": true + }, + { + "name": "itemsPerPage", + "in": "query", + "description": "The number of items per page", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 30, + "minimum": 0, + "maximum": 100 + }, + "style": "form", + "explode": true + } + ] + }, "post": { - "operationId": "restoreOrganization", + "operationId": "post_reply", "tags": [ - "Organization" + "Message" ], "responses": { - "200": { - "description": "Organization restored — refreshed organization output returned", + "201": { + "description": "Message resource created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Organization.OrganizationOutput.jsonld-Organization.read" + "$ref": "#/components/schemas/Message.MessageOutput.jsonld" } } }, @@ -33354,10 +35688,25 @@ "links": {} }, "403": { - "description": "Insufficient permissions — and, for an archived organization, held by every caller who is not a platform administrator" - }, - "404": { - "description": "Organization not found" + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} }, "422": { "description": "An error occurred", @@ -33381,13 +35730,13 @@ "links": {} } }, - "summary": "Restore Organization", - "description": "Restores the organization to ACTIVE from SUSPENDED or ARCHIVED, as an explicit, dedicated action — coexists with (does not replace) the legacy isActive: true toggle on PATCH /organizations/{id}. Requires the organization.settings.write permission, the SAME permission the legacy toggle already requires — OR platform administrator privileges, which bypass it. The bypass is not a convenience: an ARCHIVED organization refuses organization.settings.write like every other write, so a platform administrator is the only caller who can reopen one. A SUSPENDED organization keeps its self-service path. Idempotent when already active.", + "summary": "Creates a Message resource.", + "description": "Creates a Message resource.", "parameters": [ { "name": "id", "in": "path", - "description": "Organization identifier", + "description": "Message identifier", "required": true, "deprecated": false, "schema": { @@ -33396,22 +35745,33 @@ "style": "simple", "explode": false } - ] + ], + "requestBody": { + "description": "The new Message resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Message.PostReplyInput" + } + } + }, + "required": true + } } }, - "/api/organizations/{id}/suspend": { + "/api/messages/{id}/save": { "post": { - "operationId": "suspendOrganization", + "operationId": "save_message", "tags": [ - "Organization" + "Message" ], "responses": { "200": { - "description": "Organization suspended — refreshed organization output returned", + "description": "Message resource created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Organization.OrganizationOutput.jsonld-Organization.read" + "$ref": "#/components/schemas/Message.MessageOutput.jsonld" } } }, @@ -33439,13 +35799,25 @@ "links": {} }, "403": { - "description": "Insufficient permissions" - }, - "404": { - "description": "Organization not found" - }, - "409": { - "description": "Organization is archived — restore it first" + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} }, "422": { "description": "An error occurred", @@ -33469,13 +35841,13 @@ "links": {} } }, - "summary": "Suspend Organization", - "description": "Suspends the organization as an explicit, dedicated action — coexists with (does not replace) the legacy isActive: false toggle on PATCH /organizations/{id}. Requires the organization.settings.write permission, the SAME permission the legacy toggle already requires. Idempotent when already suspended.", + "summary": "Creates a Message resource.", + "description": "Creates a Message resource.", "parameters": [ { "name": "id", "in": "path", - "description": "Organization identifier", + "description": "Message identifier", "required": true, "deprecated": false, "schema": { @@ -33485,28 +35857,18 @@ "explode": false } ] - } - }, - "/api/organizations/{id}/transfer-ownership": { - "post": { - "operationId": "transferOrganizationOwnership", + }, + "delete": { + "operationId": "unsave_message", "tags": [ - "Organization" + "Message" ], "responses": { - "200": { - "description": "Ownership transferred — refreshed organization output returned", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Organization.OrganizationOutput.jsonld-Organization.read" - } - } - }, - "links": {} + "204": { + "description": "Message resource deleted" }, - "400": { - "description": "Invalid input", + "403": { + "description": "Forbidden", "content": { "application/ld+json": { "schema": { @@ -33526,44 +35888,35 @@ }, "links": {} }, - "403": { - "description": "Caller is not the organization's current owner" - }, "404": { - "description": "Organization not found, or the target user is not an active member" - }, - "409": { - "description": "Organization is archived, or the target user already owns the organization" - }, - "422": { - "description": "Missing or mismatched slug confirmation", + "description": "Not found", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation.jsonld" + "$ref": "#/components/schemas/Error.jsonld" } }, "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation" + "$ref": "#/components/schemas/Error" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation" + "$ref": "#/components/schemas/Error" } } }, "links": {} } }, - "summary": "Transfer Organization ownership", - "description": "Transfers ownership of the organization to another active member. Only the organization's CURRENT owner may call this — independent of RBAC permissions, since no permission grants the right to give away someone else's ownership. Requires a danger-zone confirmation: \"slug\" must exactly match the organization's current slug (case-insensitive, trimmed), mirroring DELETE /organizations/{id}. The new owner automatically receives the organization's system \"admin\" role if they do not already hold it.", + "summary": "Removes the Message resource.", + "description": "Removes the Message resource.", "parameters": [ { "name": "id", "in": "path", - "description": "Organization identifier", + "description": "Message identifier", "required": true, "deprecated": false, "schema": { @@ -33572,34 +35925,23 @@ "style": "simple", "explode": false } - ], - "requestBody": { - "description": "The new Organization resource", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Organization.TransferOrganizationOwnershipInput-Organization.write" - } - } - }, - "required": true - } + ] } }, - "/api/organizations/{organizationId}/audit-events": { + "/api/saved-messages": { "get": { - "operationId": "listOrganizationAuditEvents", + "operationId": "list_saved_messages", "tags": [ - "Organization" + "Message" ], "responses": { "200": { - "description": "OrganizationAuditEvent collection", + "description": "Message collection", "content": { "application/ld+json": { "schema": { "type": "object", - "description": "OrganizationAuditEvent.OrganizationAuditEventOutput.jsonld-Organization.read collection.", + "description": "Message.MessageOutput.jsonld collection.", "allOf": [ { "$ref": "#/components/schemas/HydraCollectionBaseSchema" @@ -33613,7 +35955,7 @@ "member": { "type": "array", "items": { - "$ref": "#/components/schemas/OrganizationAuditEvent.OrganizationAuditEventOutput.jsonld-Organization.read" + "$ref": "#/components/schemas/Message.MessageOutput.jsonld" } } } @@ -33645,21 +35987,9 @@ "links": {} } }, - "summary": "List organization audit events", - "description": "Returns the organization's slice of the audit ledger, newest first, for the activity feed. Requires the `organization.audit.read` permission in the target organization (held by admins through `organization.*`). The payload is deliberately narrower than the platform-level audit API: actor email, IP address, user agent, and ledger chain internals are never exposed here, regardless of platform PII settings, and metadata keys carrying PII or request context are stripped.", + "summary": "Retrieves the collection of Message resources.", + "description": "Retrieves the collection of Message resources.", "parameters": [ - { - "name": "organizationId", - "in": "path", - "description": "OrganizationAuditEvent identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - }, { "name": "page", "in": "query", @@ -33689,42 +36019,13 @@ "explode": true }, { - "name": "action", - "in": "query", - "description": "Optional exact audit action filter (e.g. organization.member_added).", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "example": "organization.member_added" - }, - "style": "form", - "explode": true - }, - { - "name": "from", - "in": "query", - "description": "Optional inclusive ISO 8601 lower bound on the occurrence datetime.", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "format": "date-time", - "example": "2026-03-01T00:00:00Z" - }, - "style": "form", - "explode": true - }, - { - "name": "to", + "name": "organization", "in": "query", - "description": "Optional inclusive ISO 8601 upper bound on the occurrence datetime.", - "required": false, + "description": "Organization IRI.", + "required": true, "deprecated": false, "schema": { - "type": "string", - "format": "date-time", - "example": "2026-03-29T23:59:59Z" + "type": "string" }, "style": "form", "explode": true @@ -33732,39 +36033,57 @@ ] } }, - "/api/organizations/{organizationId}/audit-events/export": { + "/api/conversations/{conversationId}/attachments": { "get": { - "operationId": "exportOrganizationAuditEvents", + "operationId": "list_conversation_attachments", "tags": [ - "Organization" + "Messaging" ], "responses": { "200": { - "description": "CSV stream" - }, - "204": { - "description": "OrganizationAuditEvent resource" - }, - "400": { - "description": "Malformed date filter" + "description": "Attachments retrieved", + "content": { + "application/ld+json": { + "schema": { + "type": "object", + "description": "MessagingAttachment.MessageAttachmentOutput.jsonld collection.", + "allOf": [ + { + "$ref": "#/components/schemas/HydraCollectionBaseSchema" + }, + { + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MessagingAttachment.MessageAttachmentOutput.jsonld" + } + } + } + } + ] + } + } + } }, "403": { - "description": "Member without the organization.audit.export permission" + "description": "Insufficient permissions" }, "404": { - "description": "Organization not found, or the caller is not an active member" - }, - "422": { - "description": "The filters match more rows than the export cap" + "description": "Conversation not found" } }, - "summary": "Export organization audit events (CSV)", - "description": "Streams the organization's slice of the audit ledger as CSV, using the same filters as the list endpoint. Requires the `organization.audit.export` permission, which is deliberately separate from `organization.audit.read`: reading keeps the data inside the product, exporting takes a file out. The organization is taken from the URI and is not a filter the caller can widen — unlike the platform-level `/audit-events/export`, which builds its criteria from the request and is therefore reserved to platform operators. The columns match what the read endpoint exposes: no actor email, no IP address, no user agent, no ledger chain internals.", + "summary": "List a conversation's attachments (Files tab)", + "description": "Lists the attachments posted in a conversation, most recently uploaded first.", "parameters": [ { - "name": "organizationId", + "name": "conversationId", "in": "path", - "description": "OrganizationAuditEvent identifier", + "description": "MessagingAttachment identifier", "required": true, "deprecated": false, "schema": { @@ -33774,42 +36093,29 @@ "explode": false }, { - "name": "action", - "in": "query", - "description": "Optional exact audit action filter (e.g. organization.member_added).", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "example": "organization.member_added" - }, - "style": "form", - "explode": true - }, - { - "name": "from", + "name": "page", "in": "query", - "description": "Optional inclusive ISO 8601 lower bound on the occurrence datetime.", + "description": "The collection page number", "required": false, "deprecated": false, "schema": { - "type": "string", - "format": "date-time", - "example": "2026-03-01T00:00:00Z" + "type": "integer", + "default": 1 }, "style": "form", "explode": true }, { - "name": "to", + "name": "itemsPerPage", "in": "query", - "description": "Optional inclusive ISO 8601 upper bound on the occurrence datetime.", + "description": "The number of items per page", "required": false, "deprecated": false, "schema": { - "type": "string", - "format": "date-time", - "example": "2026-03-29T23:59:59Z" + "type": "integer", + "default": 30, + "minimum": 0, + "maximum": 100 }, "style": "form", "explode": true @@ -33817,25 +36123,26 @@ ] } }, - "/api/organizations/{organizationId}/dashboard": { - "get": { - "operationId": "getOrganizationDashboard", + "/api/messages/{messageId}/attachments": { + "post": { + "operationId": "add_message_attachment", "tags": [ - "Organization" + "Messaging" ], "responses": { - "200": { - "description": "OrganizationDashboard resource", + "201": { + "description": "Attachment uploaded", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/OrganizationDashboard.OrganizationDashboardOutput.jsonld-Organization.read" + "$ref": "#/components/schemas/MessagingAttachment.MessageAttachmentOutput.jsonld" } } - } + }, + "links": {} }, - "403": { - "description": "Forbidden", + "400": { + "description": "Invalid input", "content": { "application/ld+json": { "schema": { @@ -33855,35 +36162,41 @@ }, "links": {} }, + "403": { + "description": "Insufficient permissions" + }, "404": { - "description": "Not found", + "description": "Message not found" + }, + "422": { + "description": "MIME type, size, or message state rejected", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Error.jsonld" + "$ref": "#/components/schemas/ConstraintViolation.jsonld" } }, "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/ConstraintViolation" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/ConstraintViolation" } } }, "links": {} } }, - "summary": "Get organization dashboard", - "description": "Returns lightweight organization-overview KPI data. `overview`, `alerts`, and non-`period*` health KPIs are current snapshots evaluated at `generatedAt`; `comparison` and `period*` health KPIs follow the requested period. `overview` is normalized for frontend consumption and exposes only stable keys and `summary` metric lists. `alerts` expose machine-readable `code`/`severity`/`count`, `health` exposes an iterable `metrics` list, and `comparison.metrics` exposes stable KPI cards for inspections, facilities, members, and equipment with `key`/`label`/`value` plus raw `current`/`previous`/`delta` values. Those cards represent period-over-period changes for inspections performed, facilities created, members joined, and equipment created. Legacy non-conformity opened and resolved comparison entries remain present for backward compatibility. `comparison.health.metrics` exposes percentage-based health deltas. Analytics filters are section-scoped: `facilityType` affects facilities overview and facility comparison cards, `equipmentType` and `equipmentStatus` affect equipment overview and equipment comparison cards, `inspection*` filters affect inspection KPIs and period comparisons, and `nonConformity*` filters affect non-conformity KPIs, alerts, and period comparisons. `overview.nonConformities.severityLow`/`severityMedium`/`severityHigh`/`severityCritical` are the sole exception: an org-wide, unfiltered current-snapshot breakdown by severity across every status, unaffected by `nonConformityStatus`/`nonConformitySeverity`. By default, the endpoint returns the latest 30-day window in UTC and compares it with the previous period. The maximum supported period is 366 days. Datetime bounds must be ISO 8601 strings with an explicit timezone offset, including optional microseconds. Access requires `organization.dashboard.read` plus the underlying read permissions for members, roles, facilities, equipment, and inspections. For chart or sparkline data, use the dedicated `/dashboard/trends/*` endpoints, which require `organization.inspection.read`.", + "summary": "Upload a message attachment", + "description": "Uploads a multipart file attachment to a message.", "parameters": [ { - "name": "organizationId", + "name": "messageId", "in": "path", - "description": "OrganizationDashboard identifier", + "description": "MessagingAttachment identifier", "required": true, "deprecated": false, "schema": { @@ -33891,239 +36204,450 @@ }, "style": "simple", "explode": false + } + ] + } + }, + "/api/messaging-attachments/{id}": { + "delete": { + "operationId": "delete_message_attachment", + "tags": [ + "Messaging" + ], + "responses": { + "204": { + "description": "Attachment deleted" + }, + "403": { + "description": "Insufficient permissions" + }, + "404": { + "description": "Attachment not found" }, + "412": { + "description": "Attachment revision is stale" + }, + "428": { + "description": "If-Match header is required" + } + }, + "summary": "Delete a message attachment", + "description": "Delete a message attachment", + "parameters": [ { - "name": "from", - "in": "query", - "description": "Inclusive ISO 8601 datetime lower bound for the period-scoped parts of the dashboard, with an explicit timezone offset. Optional microseconds are preserved. If omitted, defaults to 30 days before \"to\" or now. Maximum supported period: 366 days.", - "required": false, + "name": "id", + "in": "path", + "description": "MessagingAttachment identifier", + "required": true, "deprecated": false, "schema": { - "type": "string", - "format": "date-time", - "example": "2026-03-01T00:00:00Z" + "type": "string" }, - "style": "form", - "explode": true + "style": "simple", + "explode": false + } + ] + } + }, + "/api/messaging-attachments/{id}/content": { + "get": { + "operationId": "download_message_attachment", + "tags": [ + "Messaging" + ], + "responses": { + "200": { + "description": "Attachment file content" + }, + "204": { + "description": "MessagingAttachmentContent resource" + }, + "403": { + "description": "Insufficient permissions" }, + "404": { + "description": "Attachment (or its stored content) not found" + } + }, + "summary": "Download a message attachment", + "description": "Streams the stored file bytes with Content-Disposition: attachment. Gated by the same access rule as reading the owning conversation.", + "parameters": [ { - "name": "to", - "in": "query", - "description": "Inclusive ISO 8601 datetime upper bound for the period-scoped parts of the dashboard, with an explicit timezone offset. Optional microseconds are preserved. If omitted, defaults to the current datetime. Maximum supported period: 366 days.", - "required": false, + "name": "id", + "in": "path", + "description": "MessagingAttachmentContent identifier", + "required": true, "deprecated": false, "schema": { - "type": "string", - "format": "date-time", - "example": "2026-03-29T23:59:59Z" + "type": "string" }, - "style": "form", - "explode": true + "style": "simple", + "explode": false + } + ] + } + }, + "/api/conversations/{conversationId}/links": { + "get": { + "operationId": "list_conversation_links", + "tags": [ + "MessagingLink" + ], + "responses": { + "200": { + "description": "MessagingLink collection", + "content": { + "application/ld+json": { + "schema": { + "type": "object", + "description": "MessagingLink.MessagingLinkOutput.jsonld collection.", + "allOf": [ + { + "$ref": "#/components/schemas/HydraCollectionBaseSchema" + }, + { + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "array", + "items": { + "$ref": "#/components/schemas/MessagingLink.MessagingLinkOutput.jsonld" + } + } + } + } + ] + } + } + } }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + } + }, + "summary": "Retrieves the collection of MessagingLink resources.", + "description": "Retrieves the collection of MessagingLink resources.", + "parameters": [ { - "name": "compare", - "in": "query", - "description": "Whether to include previous-period scalar comparison metrics for KPI cards. Defaults to true.", - "required": false, + "name": "conversationId", + "in": "path", + "description": "MessagingLink identifier", + "required": true, "deprecated": false, "schema": { - "type": "boolean", - "default": true, - "example": true + "type": "string" }, - "style": "form", - "explode": true + "style": "simple", + "explode": false }, { - "name": "timezone", + "name": "page", "in": "query", - "description": "IANA timezone used for rendered period values. Defaults to UTC when omitted and not implied by the request bounds. Required when the requested period spans DST, mixes offsets, or uses non-UTC numeric offsets.", + "description": "The collection page number", "required": false, "deprecated": false, "schema": { - "type": "string", - "example": "Europe/Paris" + "type": "integer", + "default": 1 }, "style": "form", "explode": true }, { - "name": "facilityType", + "name": "itemsPerPage", "in": "query", - "description": "Optional facility type filter applied to the facilities section only.", + "description": "The number of items per page", "required": false, "deprecated": false, "schema": { - "type": "string", - "enum": [ - "site", - "building", - "floor", - "zone", - "area" - ], - "example": "site" + "type": "integer", + "default": 30, + "minimum": 0, + "maximum": 100 }, "style": "form", "explode": true + } + ] + } + }, + "/api/notification-types": { + "get": { + "operationId": "notification_types_list", + "tags": [ + "Notification" + ], + "responses": { + "200": { + "description": "Notification types retrieved successfully", + "content": { + "application/ld+json": { + "schema": { + "type": "object", + "description": "NotificationType.NotificationTypeOutput.jsonld-notification_type.read collection.", + "allOf": [ + { + "$ref": "#/components/schemas/HydraCollectionBaseSchema" + }, + { + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NotificationType.NotificationTypeOutput.jsonld-notification_type.read" + } + } + } + } + ] + } + } + } + }, + "401": { + "description": "Authentication required" }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + } + }, + "summary": "List notification types", + "description": "Returns all known notification types with their categories. Useful for building type selector components on the frontend.", + "parameters": [ { - "name": "equipmentType", + "name": "page", "in": "query", - "description": "Optional equipment type filter applied to the equipment section only.", + "description": "The collection page number", "required": false, "deprecated": false, "schema": { - "type": "string", - "enum": [ - "fire_extinguisher", - "smoke_detector", - "heat_detector", - "sprinkler", - "fire_alarm_panel", - "hydrant", - "fire_door", - "emergency_lighting", - "access_control", - "camera", - "gas_detector", - "other" - ], - "example": "fire_extinguisher" + "type": "integer", + "default": 1 }, "style": "form", "explode": true + } + ], + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/api/notifications": { + "get": { + "operationId": "notifications_list", + "tags": [ + "Notification" + ], + "responses": { + "200": { + "description": "Notifications retrieved successfully", + "content": { + "application/ld+json": { + "schema": { + "type": "object", + "description": "Notification.NotificationOutput.jsonld-notification.read collection.", + "allOf": [ + { + "$ref": "#/components/schemas/HydraCollectionBaseSchema" + }, + { + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Notification.NotificationOutput.jsonld-notification.read" + } + } + } + } + ] + } + } + } + }, + "401": { + "description": "Authentication required" }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + } + }, + "summary": "List notifications", + "description": "Returns a paginated collection of notifications for the authenticated user. Read notifications from low-value categories may be omitted from the default list after a retention delay.", + "parameters": [ { - "name": "equipmentStatus", + "name": "page", "in": "query", - "description": "Optional equipment status filter applied to the equipment section only.", + "description": "The collection page number", "required": false, "deprecated": false, "schema": { - "type": "string", - "enum": [ - "in_stock", - "operational", - "under_maintenance", - "decommissioned" - ], - "example": "operational" + "type": "integer", + "default": 1 }, "style": "form", "explode": true }, { - "name": "inspectionStatus", + "name": "itemsPerPage", "in": "query", - "description": "Optional inspection status filter applied to inspection KPIs, period health, and inspection comparisons.", + "description": "The number of items per page", "required": false, "deprecated": false, "schema": { - "type": "string", - "enum": [ - "draft", - "submitted", - "closed" - ], - "example": "closed" + "type": "integer", + "default": 20, + "minimum": 0, + "maximum": 100 }, "style": "form", "explode": true }, { - "name": "inspectionResult", + "name": "unreadOnly", "in": "query", - "description": "Optional inspection result filter applied to inspection KPIs, period health, and inspection comparisons.", + "description": "When true, returns only unread notifications.", "required": false, "deprecated": false, "schema": { - "type": "string", - "enum": [ - "pass", - "fail", - "partial" - ], - "example": "pass" + "type": "boolean" }, "style": "form", "explode": true }, { - "name": "inspectorType", + "name": "type", "in": "query", - "description": "Optional inspector type filter applied to inspection KPIs, period health, and inspection comparisons.", + "description": "Filter by exact notification type (e.g. `organization.invitation`). See `GET /notification-types` for the full list.", "required": false, "deprecated": false, "schema": { - "type": "string", - "enum": [ - "user", - "external" - ], - "example": "user" + "type": "string" }, "style": "form", "explode": true }, { - "name": "nonConformityStatus", + "name": "category", "in": "query", - "description": "Optional non-conformity status filter applied to non-conformity KPIs, alerts, period health, and non-conformity comparisons.", + "description": "Filter by category prefix (e.g. `organization`, `system`). Ignored when `type` is also provided.", "required": false, "deprecated": false, "schema": { - "type": "string", - "enum": [ - "open", - "in_progress", - "done", - "waived" - ], - "example": "open" + "type": "string" }, "style": "form", "explode": true }, { - "name": "nonConformitySeverity", + "name": "organization", "in": "query", - "description": "Optional non-conformity severity filter applied to non-conformity KPIs, alerts, period health, and non-conformity comparisons.", + "description": "Filter by organization identifier. When omitted, notifications across all organizations are returned, including account-level notifications that have no organization.", "required": false, "deprecated": false, "schema": { "type": "string", - "enum": [ - "low", - "medium", - "high", - "critical" - ], - "example": "critical" + "format": "uuid" }, "style": "form", "explode": true } + ], + "security": [ + { + "bearerAuth": [] + } ] } }, - "/api/organizations/{organizationId}/dashboard/trends/equipment-created": { + "/api/notifications/preferences": { "get": { - "operationId": "getOrganizationDashboardEquipmentCreatedTrend", + "operationId": "notification_preferences_get", "tags": [ - "Organization" + "Notification" ], "responses": { "200": { - "description": "OrganizationDashboardTrend resource", + "description": "Notification preferences retrieved successfully", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/OrganizationDashboardTrend.OrganizationDashboardTrendOutput.jsonld-Organization.read" + "$ref": "#/components/schemas/Notification.NotificationPreferencesOutput.jsonld-notification.preferences.read" } } } }, + "401": { + "description": "Authentication required" + }, "403": { "description": "Forbidden", "content": { @@ -34167,161 +36691,55 @@ "links": {} } }, - "summary": "Get equipment created trend", - "description": "Returns the equipment-created trend as a single chart-ready series. Use this endpoint when a chart needs its own independent period or granularity, separate from the aggregate `/dashboard` payload. Access requires `organization.equipment.read`.", - "parameters": [ + "summary": "Get notification preferences", + "description": "Returns the authenticated user's customized per-category delivery preferences. A category with no entry is enabled on every channel (email and Mercure); only explicit customizations are stored.", + "parameters": [], + "security": [ { - "name": "organizationId", - "in": "path", - "description": "OrganizationDashboardTrend identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" + "bearerAuth": [] + } + ] + }, + "patch": { + "operationId": "notification_preferences_update", + "tags": [ + "Notification" + ], + "responses": { + "200": { + "description": "Notification preferences updated successfully", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Notification.NotificationPreferencesOutput.jsonld-notification.preferences.read" + } + } }, - "style": "simple", - "explode": false + "links": {} }, - { - "name": "from", - "in": "query", - "description": "Inclusive ISO 8601 datetime lower bound for the trend period, with an explicit timezone offset. Optional microseconds are preserved.", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "format": "date-time", - "example": "2026-03-01T00:00:00Z" - }, - "style": "form", - "explode": true - }, - { - "name": "to", - "in": "query", - "description": "Inclusive ISO 8601 datetime upper bound for the trend period, with an explicit timezone offset. Optional microseconds are preserved.", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "format": "date-time", - "example": "2026-03-29T23:59:59Z" - }, - "style": "form", - "explode": true - }, - { - "name": "compare", - "in": "query", - "description": "Whether to include previous-period comparison series. Defaults to true.", - "required": false, - "deprecated": false, - "schema": { - "type": "boolean", - "default": true, - "example": true - }, - "style": "form", - "explode": true - }, - { - "name": "granularity", - "in": "query", - "description": "Trend aggregation granularity. Allowed values: day, week, month, auto. Defaults to day.", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "enum": [ - "day", - "week", - "month", - "auto" - ], - "default": "day", - "example": "week" - }, - "style": "form", - "explode": true - }, - { - "name": "timezone", - "in": "query", - "description": "IANA timezone used for bucket boundaries and rendered period values. Required when the requested period spans DST, mixes offsets, or uses non-UTC numeric offsets.", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "example": "Europe/Paris" - }, - "style": "form", - "explode": true - }, - { - "name": "equipmentType", - "in": "query", - "description": "Optional equipment type filter applied to the equipment-created trend only.", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "enum": [ - "fire_extinguisher", - "smoke_detector", - "heat_detector", - "sprinkler", - "fire_alarm_panel", - "hydrant", - "fire_door", - "emergency_lighting", - "access_control", - "camera", - "gas_detector", - "other" - ], - "example": "fire_extinguisher" - }, - "style": "form", - "explode": true - }, - { - "name": "equipmentStatus", - "in": "query", - "description": "Optional equipment status filter applied to the equipment-created trend only.", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "enum": [ - "in_stock", - "operational", - "under_maintenance", - "decommissioned" - ], - "example": "operational" - }, - "style": "form", - "explode": true - } - ] - } - }, - "/api/organizations/{organizationId}/dashboard/trends/facilities-created": { - "get": { - "operationId": "getOrganizationDashboardFacilitiesCreatedTrend", - "tags": [ - "Organization" - ], - "responses": { - "200": { - "description": "OrganizationDashboardTrend resource", + "400": { + "description": "Invalid request - validation failed", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/OrganizationDashboardTrend.OrganizationDashboardTrendOutput.jsonld-Organization.read" + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" } } - } + }, + "links": {} + }, + "401": { + "description": "Authentication required" }, "403": { "description": "Forbidden", @@ -34364,137 +36782,91 @@ } }, "links": {} - } - }, - "summary": "Get facilities created trend", - "description": "Returns the facilities-created trend as a single chart-ready series. Use this endpoint when a chart needs its own independent period or granularity, separate from the aggregate `/dashboard` payload. Access requires `organization.facilities.read`.", - "parameters": [ - { - "name": "organizationId", - "in": "path", - "description": "OrganizationDashboardTrend identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - }, - { - "name": "from", - "in": "query", - "description": "Inclusive ISO 8601 datetime lower bound for the trend period, with an explicit timezone offset. Optional microseconds are preserved.", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "format": "date-time", - "example": "2026-03-01T00:00:00Z" - }, - "style": "form", - "explode": true - }, - { - "name": "to", - "in": "query", - "description": "Inclusive ISO 8601 datetime upper bound for the trend period, with an explicit timezone offset. Optional microseconds are preserved.", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "format": "date-time", - "example": "2026-03-29T23:59:59Z" - }, - "style": "form", - "explode": true - }, - { - "name": "compare", - "in": "query", - "description": "Whether to include previous-period comparison series. Defaults to true.", - "required": false, - "deprecated": false, - "schema": { - "type": "boolean", - "default": true, - "example": true - }, - "style": "form", - "explode": true - }, - { - "name": "granularity", - "in": "query", - "description": "Trend aggregation granularity. Allowed values: day, week, month, auto. Defaults to day.", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "enum": [ - "day", - "week", - "month", - "auto" - ], - "default": "day", - "example": "week" - }, - "style": "form", - "explode": true }, - { - "name": "timezone", - "in": "query", - "description": "IANA timezone used for bucket boundaries and rendered period values. Required when the requested period spans DST, mixes offsets, or uses non-UTC numeric offsets.", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "example": "Europe/Paris" + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } }, - "style": "form", - "explode": true + "links": {} + } + }, + "summary": "Update notification preferences", + "description": "Upserts one or more per-category delivery preferences for the authenticated user and returns the full customized set. This suppresses email/Mercure delivery only — matching notifications are still persisted and remain visible in the in-app list.", + "parameters": [], + "requestBody": { + "description": "The updated Notification resource", + "content": { + "application/merge-patch+json": { + "schema": { + "$ref": "#/components/schemas/Notification.UpdateNotificationPreferencesInput-notification.preferences.write.jsonMergePatch" + } + } }, + "required": true + }, + "security": [ { - "name": "facilityType", - "in": "query", - "description": "Optional facility type filter applied to the facilities-created trend only.", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "enum": [ - "site", - "building", - "floor", - "zone", - "area" - ], - "example": "site" - }, - "style": "form", - "explode": true + "bearerAuth": [] } ] } }, - "/api/organizations/{organizationId}/dashboard/trends/inspections": { - "get": { - "operationId": "getOrganizationDashboardInspectionsTrend", + "/api/notifications/read-all": { + "patch": { + "operationId": "notification_mark_all_as_read", "tags": [ - "Organization" + "Notification" ], "responses": { "200": { - "description": "OrganizationDashboardTrend resource", + "description": "Notifications marked as read", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/OrganizationDashboardTrend.OrganizationDashboardTrendOutput.jsonld-Organization.read" + "$ref": "#/components/schemas/Notification.MarkAllNotificationsAsReadOutput.jsonld-notification.mark_all_as_read" } } - } + }, + "links": {} + }, + "400": { + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "401": { + "description": "Authentication required" }, "403": { "description": "Forbidden", @@ -34537,171 +36909,73 @@ } }, "links": {} + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} } }, - "summary": "Get inspections trend", - "description": "Returns the inspections-performed trend as a single chart-ready series. Use this endpoint when a chart needs its own independent period or granularity, separate from the aggregate `/dashboard` payload. Access requires `organization.inspection.read`.", + "summary": "Mark all notifications as read", + "description": "Marks every unread notification of the authenticated user as read. Idempotent: calling it again once everything is read affects zero notifications.", "parameters": [ { - "name": "organizationId", - "in": "path", - "description": "OrganizationDashboardTrend identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - }, - { - "name": "from", - "in": "query", - "description": "Inclusive ISO 8601 datetime lower bound for the trend period, with an explicit timezone offset. Optional microseconds are preserved.", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "format": "date-time", - "example": "2026-03-01T00:00:00Z" - }, - "style": "form", - "explode": true - }, - { - "name": "to", - "in": "query", - "description": "Inclusive ISO 8601 datetime upper bound for the trend period, with an explicit timezone offset. Optional microseconds are preserved.", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "format": "date-time", - "example": "2026-03-29T23:59:59Z" - }, - "style": "form", - "explode": true - }, - { - "name": "compare", - "in": "query", - "description": "Whether to include previous-period comparison series. Defaults to true.", - "required": false, - "deprecated": false, - "schema": { - "type": "boolean", - "default": true, - "example": true - }, - "style": "form", - "explode": true - }, - { - "name": "granularity", - "in": "query", - "description": "Trend aggregation granularity. Allowed values: day, week, month, auto. Defaults to day.", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "enum": [ - "day", - "week", - "month", - "auto" - ], - "default": "day", - "example": "week" - }, - "style": "form", - "explode": true - }, - { - "name": "timezone", - "in": "query", - "description": "IANA timezone used for bucket boundaries and rendered period values. Required when the requested period spans DST, mixes offsets, or uses non-UTC numeric offsets.", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "example": "Europe/Paris" - }, - "style": "form", - "explode": true - }, - { - "name": "inspectionStatus", - "in": "query", - "description": "Optional inspection status filter applied to the inspection trend only.", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "enum": [ - "draft", - "submitted", - "closed" - ], - "example": "closed" - }, - "style": "form", - "explode": true - }, - { - "name": "inspectionResult", + "name": "organization", "in": "query", - "description": "Optional inspection result filter applied to the inspection trend only.", + "description": "Filter by organization identifier. When omitted, notifications across all organizations (and account-level ones) are marked as read.", "required": false, "deprecated": false, "schema": { "type": "string", - "enum": [ - "pass", - "fail", - "partial" - ], - "example": "pass" + "format": "uuid" }, "style": "form", "explode": true - }, + } + ], + "security": [ { - "name": "inspectorType", - "in": "query", - "description": "Optional inspector type filter applied to the inspection trend only.", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "enum": [ - "user", - "external" - ], - "example": "user" - }, - "style": "form", - "explode": true + "bearerAuth": [] } ] } }, - "/api/organizations/{organizationId}/dashboard/trends/non-conformities-opened": { + "/api/notifications/subscription": { "get": { - "operationId": "getOrganizationDashboardNonConformitiesOpenedTrend", + "operationId": "notification_mercure_subscription", "tags": [ - "Organization" + "Notification" ], "responses": { "200": { - "description": "OrganizationDashboardTrend resource", + "description": "Mercure subscription token returned successfully", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/OrganizationDashboardTrend.OrganizationDashboardTrendOutput.jsonld-Organization.read" + "$ref": "#/components/schemas/Notification.MercureSubscriptionOutput.jsonld-notification.mercure_subscription" } } } }, + "401": { + "description": "Authentication required" + }, "403": { "description": "Forbidden", "content": { @@ -34745,167 +37019,36 @@ "links": {} } }, - "summary": "Get non-conformities opened trend", - "description": "Returns the non-conformities-opened trend as a single chart-ready series. Use this endpoint when a chart needs its own independent period or granularity, separate from the aggregate `/dashboard` payload. Access requires `organization.inspection.read`.", - "parameters": [ + "summary": "Get Mercure subscription token", + "description": "Returns a Mercure subscriber JWT and the private SSE topic for the authenticated user. The client must pass this token in the Authorization header (Bearer) when opening an EventSource connection to the Mercure hub.", + "parameters": [], + "security": [ { - "name": "organizationId", - "in": "path", - "description": "OrganizationDashboardTrend identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - }, - { - "name": "from", - "in": "query", - "description": "Inclusive ISO 8601 datetime lower bound for the trend period, with an explicit timezone offset. Optional microseconds are preserved.", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "format": "date-time", - "example": "2026-03-01T00:00:00Z" - }, - "style": "form", - "explode": true - }, - { - "name": "to", - "in": "query", - "description": "Inclusive ISO 8601 datetime upper bound for the trend period, with an explicit timezone offset. Optional microseconds are preserved.", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "format": "date-time", - "example": "2026-03-29T23:59:59Z" - }, - "style": "form", - "explode": true - }, - { - "name": "compare", - "in": "query", - "description": "Whether to include previous-period comparison series. Defaults to true.", - "required": false, - "deprecated": false, - "schema": { - "type": "boolean", - "default": true, - "example": true - }, - "style": "form", - "explode": true - }, - { - "name": "granularity", - "in": "query", - "description": "Trend aggregation granularity. Allowed values: day, week, month, auto. Defaults to day.", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "enum": [ - "day", - "week", - "month", - "auto" - ], - "default": "day", - "example": "week" - }, - "style": "form", - "explode": true - }, - { - "name": "timezone", - "in": "query", - "description": "IANA timezone used for bucket boundaries and rendered period values. Required when the requested period spans DST, mixes offsets, or uses non-UTC numeric offsets.", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "example": "Europe/Paris" - }, - "style": "form", - "explode": true - }, - { - "name": "nonConformityStatus", - "in": "query", - "description": "Optional non-conformity status filter applied to the non-conformity trend only.", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "enum": [ - "open", - "in_progress", - "done", - "waived" - ], - "example": "open" - }, - "style": "form", - "explode": true - }, - { - "name": "nonConformitySeverity", - "in": "query", - "description": "Optional non-conformity severity filter applied to the non-conformity trend only.", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "enum": [ - "low", - "medium", - "high", - "critical" - ], - "example": "critical" - }, - "style": "form", - "explode": true - }, - { - "name": "metrics", - "in": "query", - "description": "Optional comma-separated list of additional non-conformity metric identifiers to combine into `seriesByMetric`, sharing this call's resolved period, timezone and granularity so a two-series (opened vs resolved) chart can render from one request instead of two independently-bucketed calls. Allowed values: non_conformities_opened, non_conformities_resolved.", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "example": "non_conformities_opened,non_conformities_resolved" - }, - "style": "form", - "explode": true + "bearerAuth": [] } ] } }, - "/api/organizations/{organizationId}/dashboard/trends/non-conformities-resolved": { + "/api/notifications/unread-count": { "get": { - "operationId": "getOrganizationDashboardNonConformitiesResolvedTrend", + "operationId": "notification_unread_count", "tags": [ - "Organization" + "Notification" ], "responses": { "200": { - "description": "OrganizationDashboardTrend resource", + "description": "Unread notification count returned successfully", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/OrganizationDashboardTrend.OrganizationDashboardTrendOutput.jsonld-Organization.read" + "$ref": "#/components/schemas/Notification.UnreadNotificationsCountOutput.jsonld-notification.unread_count" } } } }, + "401": { + "description": "Authentication required" + }, "403": { "description": "Forbidden", "content": { @@ -34949,170 +37092,52 @@ "links": {} } }, - "summary": "Get non-conformities resolved trend", - "description": "Returns the non-conformities-resolved trend as a single chart-ready series. Use this endpoint when a chart needs its own independent period or granularity, separate from the aggregate `/dashboard` payload. Access requires `organization.inspection.read`.", + "summary": "Get unread notifications count", + "description": "Returns the unread notification count for the authenticated user.", "parameters": [ { - "name": "organizationId", - "in": "path", - "description": "OrganizationDashboardTrend identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - }, - { - "name": "from", - "in": "query", - "description": "Inclusive ISO 8601 datetime lower bound for the trend period, with an explicit timezone offset. Optional microseconds are preserved.", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "format": "date-time", - "example": "2026-03-01T00:00:00Z" - }, - "style": "form", - "explode": true - }, - { - "name": "to", - "in": "query", - "description": "Inclusive ISO 8601 datetime upper bound for the trend period, with an explicit timezone offset. Optional microseconds are preserved.", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "format": "date-time", - "example": "2026-03-29T23:59:59Z" - }, - "style": "form", - "explode": true - }, - { - "name": "compare", - "in": "query", - "description": "Whether to include previous-period comparison series. Defaults to true.", - "required": false, - "deprecated": false, - "schema": { - "type": "boolean", - "default": true, - "example": true - }, - "style": "form", - "explode": true - }, - { - "name": "granularity", - "in": "query", - "description": "Trend aggregation granularity. Allowed values: day, week, month, auto. Defaults to day.", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "enum": [ - "day", - "week", - "month", - "auto" - ], - "default": "day", - "example": "week" - }, - "style": "form", - "explode": true - }, - { - "name": "timezone", - "in": "query", - "description": "IANA timezone used for bucket boundaries and rendered period values. Required when the requested period spans DST, mixes offsets, or uses non-UTC numeric offsets.", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "example": "Europe/Paris" - }, - "style": "form", - "explode": true - }, - { - "name": "nonConformityStatus", - "in": "query", - "description": "Optional non-conformity status filter applied to the non-conformity trend only.", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "enum": [ - "open", - "in_progress", - "done", - "waived" - ], - "example": "open" - }, - "style": "form", - "explode": true - }, - { - "name": "nonConformitySeverity", + "name": "organization", "in": "query", - "description": "Optional non-conformity severity filter applied to the non-conformity trend only.", + "description": "Filter by organization identifier. When omitted, unread notifications across all organizations (and account-level ones) are counted.", "required": false, "deprecated": false, "schema": { "type": "string", - "enum": [ - "low", - "medium", - "high", - "critical" - ], - "example": "critical" + "format": "uuid" }, "style": "form", "explode": true - }, + } + ], + "security": [ { - "name": "metrics", - "in": "query", - "description": "Optional comma-separated list of additional non-conformity metric identifiers to combine into `seriesByMetric`, sharing this call's resolved period, timezone and granularity so a two-series (opened vs resolved) chart can render from one request instead of two independently-bucketed calls. Allowed values: non_conformities_opened, non_conformities_resolved.", - "required": false, - "deprecated": false, - "schema": { - "type": "string", - "example": "non_conformities_opened,non_conformities_resolved" - }, - "style": "form", - "explode": true + "bearerAuth": [] } ] } }, - "/api/organizations/{organizationId}/logo": { - "post": { - "operationId": "uploadOrganizationLogo", + "/api/notifications/{id}": { + "get": { + "operationId": "notification_get", "tags": [ - "Organization" + "Notification" ], "responses": { "200": { - "description": "Logo uploaded — organization output with updated logoUrl returned", + "description": "Notification retrieved successfully", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Organization.OrganizationOutput.jsonld-Organization.read" + "$ref": "#/components/schemas/Notification.NotificationOutput.jsonld-notification.read" } } - }, - "links": {} + } }, - "400": { - "description": "Invalid input", + "401": { + "description": "Authentication required" + }, + "403": { + "description": "Forbidden", "content": { "application/ld+json": { "schema": { @@ -35132,41 +37157,17 @@ }, "links": {} }, - "403": { - "description": "Insufficient permissions" - }, "404": { - "description": "Organization not found" - }, - "422": { - "description": "Invalid file — missing, too large, or unsupported MIME type", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" - } - } - }, - "links": {} + "description": "Notification not found" } }, - "summary": "Upload Organization logo", - "description": "Uploads a logo image for the organization. Accepted formats: JPEG, PNG, WebP, GIF (max 5 MB). The image is scaled down to a single WebP variant. Requires the organization.settings.write permission.", + "summary": "Get notification", + "description": "Returns a notification owned by the authenticated user.", "parameters": [ { - "name": "organizationId", + "name": "id", "in": "path", - "description": "Organization identifier", + "description": "Notification identifier", "required": true, "deprecated": false, "schema": { @@ -35176,116 +37177,51 @@ "explode": false } ], - "requestBody": { - "description": "Logo image file (JPEG, PNG, WebP or GIF, max 5 MB)", - "content": { - "multipart/form-data": { - "schema": { - "type": "object", - "required": [ - "logo" - ], - "properties": { - "logo": { - "type": "string", - "format": "binary", - "description": "Image file (JPEG, PNG, WebP or GIF), max 5 MB" - } - } - } - } - }, - "required": true - } - }, - "delete": { - "operationId": "removeOrganizationLogo", - "tags": [ - "Organization" - ], - "responses": { - "204": { - "description": "Logo removed" - }, - "403": { - "description": "Insufficient permissions" - }, - "404": { - "description": "Organization not found" - }, - "409": { - "description": "Organization is archived — restore it first" - } - }, - "summary": "Remove Organization logo", - "description": "Removes the organization logo. Requires the organization.settings.write permission (the same permission required to upload it). Idempotent when the organization has no logo.", - "parameters": [ + "security": [ { - "name": "organizationId", - "in": "path", - "description": "Organization identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false + "bearerAuth": [] } ] } }, - "/api/organizations/{organizationId}/logo.webp": { - "get": { - "operationId": "getOrganizationLogo", + "/api/notifications/{id}/read": { + "patch": { + "operationId": "notification_mark_as_read", "tags": [ - "Organization" + "Notification" ], "responses": { "200": { - "description": "Logo image (WebP)" - }, - "204": { - "description": "Organization resource" - }, - "404": { - "description": "Organization logo not found" - } - }, - "summary": "Get Organization logo", - "description": "Streams the organization logo as WebP. Public endpoint.", - "parameters": [ - { - "name": "organizationId", - "in": "path", - "description": "Organization identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" + "description": "Notification marked as read", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Notification.NotificationOutput.jsonld-notification.read" + } + } }, - "style": "simple", - "explode": false - } - ] - } - }, - "/api/organizations/{organizationId}/navigation-counters": { - "get": { - "operationId": "getOrganizationNavigationCounters", - "tags": [ - "Organization" - ], - "responses": { - "200": { - "description": "Navigation counters retrieved successfully", + "links": {} + }, + "400": { + "description": "Invalid input", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/OrganizationNavigationCounters.OrganizationNavigationCountersOutput.jsonld-Organization.read" + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" } } - } + }, + "links": {} }, "401": { "description": "Authentication required" @@ -35312,16 +37248,37 @@ "links": {} }, "404": { - "description": "Organization not found or the authenticated user is not an active member" + "description": "Notification not found" + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} } }, - "summary": "Get organization sidebar navigation counters", - "description": "Returns lightweight badge counters for the frontend sidebar navigation: `openInterventions` (field interventions not yet `published`/`abandoned`) and `openNonConformities` (non-conformities currently `open` or `in_progress`). The caller only needs to be an ACTIVE member of the organization; each counter individually falls back to 0 (never a 403) when the caller lacks the underlying read permission (`organization.interventions.read` / `organization.inspection.read` respectively), mirroring how the organization dashboard degrades its own interventions section.", + "summary": "Mark notification as read", + "description": "Marks one notification as read for the authenticated user.", "parameters": [ { - "name": "organizationId", + "name": "id", "in": "path", - "description": "OrganizationNavigationCounters identifier", + "description": "Notification identifier", "required": true, "deprecated": false, "schema": { @@ -35338,30 +37295,37 @@ ] } }, - "/api/organizations/{organizationId}/quota": { + "/api/.well-known/jwks.json": { "get": { - "operationId": "getOrganizationQuota", + "operationId": "jwks", "tags": [ - "Organization" + "OAuth2" ], "responses": { "200": { - "description": "Quota usage retrieved", + "description": "JWKS returned successfully", "content": { - "application/ld+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/Organization.OrganizationQuotaOutput.jsonld-Organization.read" + "$ref": "#/components/schemas/Discovery.JwksOutput-token.read" } } - } - }, - "403": { - "description": "Insufficient permissions" - }, - "404": { - "description": "Not found", - "content": { - "application/ld+json": { + }, + "links": { + "GetOpenIDConfig": { + "operationId": "openid_configuration", + "description": "Get the full OpenID Connect configuration" + }, + "IntrospectToken": { + "operationId": "introspect_token", + "description": "Introspect a token to validate it server-side" + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/ld+json": { "schema": { "$ref": "#/components/schemas/Error.jsonld" } @@ -35380,49 +37344,123 @@ "links": {} } }, - "summary": "Get Organization quota usage", - "description": "Returns the current usage and plan limit of each capped resource (members, facilities, equipment, inspections). Requires the organization.read permission.", - "parameters": [ - { - "name": "organizationId", - "in": "path", - "description": "Organization identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - } - ] + "summary": "JSON Web Key Set", + "description": "Returns the public keys used to verify JWT signatures as defined in RFC 7517. Use these keys to validate access tokens issued by this server.", + "parameters": [] } }, - "/api/organizations/{organizationId}/search": { + "/api/.well-known/openid-configuration": { "get": { - "operationId": "searchOrganization", + "operationId": "openid_configuration", "tags": [ - "Organization" + "OAuth2" ], "responses": { "200": { - "description": "Search results retrieved", + "description": "OpenID Connect configuration returned successfully", "content": { - "application/ld+json": { + "application/json": { "schema": { - "$ref": "#/components/schemas/OrganizationSearch.OrganizationSearchOutput.jsonld-Organization.read" + "$ref": "#/components/schemas/Discovery.OpenIdConfigurationOutput-token.read" + } + } + }, + "links": { + "GetJWKS": { + "operationId": "jwks", + "description": "Get the JSON Web Key Set for signature verification", + "parameters": { + "url": "$response.body#/jwks_uri" + } + }, + "IssueToken": { + "operationId": "token", + "description": "Issue an access token using the token endpoint", + "parameters": { + "url": "$response.body#/token_endpoint" + } + }, + "IntrospectToken": { + "operationId": "introspect_token", + "description": "Introspect a token using the introspection endpoint", + "parameters": { + "url": "$response.body#/introspection_endpoint" + } + }, + "RevokeToken": { + "operationId": "revoke_token", + "description": "Revoke a token using the revocation endpoint", + "parameters": { + "url": "$response.body#/revocation_endpoint" + } + }, + "GetUserInfo": { + "operationId": "userinfo", + "description": "Get user information using the userinfo endpoint", + "parameters": { + "url": "$response.body#/userinfo_endpoint" + } + }, + "EndSession": { + "operationId": "end_session", + "description": "Terminate the OpenID Connect session", + "parameters": { + "url": "$response.body#/end_session_endpoint" } } } }, + "404": { + "description": "Not found", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + } + }, + "summary": "OAuth2/OIDC Discovery", + "description": "Returns the OAuth2 Authorization Server Metadata document (RFC 8414) with optional OpenID Connect extensions when available.", + "parameters": [] + } + }, + "/api/oauth2/authorize": { + "get": { + "operationId": "authorize", + "tags": [ + "OAuth2" + ], + "responses": { + "204": { + "description": "OAuth2 resource" + }, + "302": { + "description": "Authorization code issued (redirect to redirect_uri)." + }, "400": { - "description": "Missing q parameter, or q shorter than 2 / longer than 100 characters" + "description": "Invalid authorization request." }, "401": { - "description": "Authentication required" + "description": "Authentication required." }, "403": { - "description": "Forbidden", + "description": "User consent required." + }, + "404": { + "description": "Not found", "content": { "application/ld+json": { "schema": { @@ -35442,62 +37480,184 @@ }, "links": {} }, - "404": { - "description": "Organization not found or the caller is not an active member — deliberately not 403, which would confirm it exists" + "429": { + "description": "Too many authorization requests" } }, - "summary": "Search across an organization", - "description": "Case-insensitive free-text search returning a flat `results` list, grouped by type in a stable order (`equipment`, `facility`, `intervention`, `inspection`, `non_conformity`), with at most 5 hits per type ordered by most recent update. Each hit carries `type`, `id`, `title` and optional `subtitle`/`extra`; the frontend builds the target route from `type` + `id` — the API ships no URL. Matched fields per type: equipment type/brand/model/serial number/location label; facility name/code/address; intervention name/number; inspection checklist reference code or inspection id; non-conformity description. The caller must be an ACTIVE member of the organization (a non-member gets 404, never a hint the organization exists); each type is then individually soft-gated on its read permission (`organization.equipment.read`, `organization.facilities.read`, `organization.interventions.read`, `organization.inspection.read` for both inspections and non-conformities) — a member without a permission simply gets no rows of that type, never an error.", + "summary": "Authorize Client", + "description": "Initiate the OAuth2 authorization code flow (PKCE required).", "parameters": [ { - "name": "organizationId", - "in": "path", - "description": "OrganizationSearch identifier", + "name": "response_type", + "in": "query", + "description": "OAuth2 response type (must be code)", + "required": true, + "deprecated": false, + "schema": { + "type": "string", + "enum": [ + "code" + ] + }, + "style": "form", + "explode": true + }, + { + "name": "client_id", + "in": "query", + "description": "OAuth2 client identifier (UUID)", + "required": true, + "deprecated": false, + "schema": { + "type": "string", + "format": "uuid" + }, + "style": "form", + "explode": true + }, + { + "name": "redirect_uri", + "in": "query", + "description": "Redirect URI registered for the client", "required": true, "deprecated": false, + "schema": { + "type": "string", + "format": "uri" + }, + "style": "form", + "explode": true + }, + { + "name": "scope", + "in": "query", + "description": "Space-separated list of requested scopes", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "example": "openid profile email" + }, + "style": "form", + "explode": true + }, + { + "name": "state", + "in": "query", + "description": "Opaque state value returned to the client", + "required": false, + "deprecated": false, "schema": { "type": "string" }, - "style": "simple", - "explode": false + "style": "form", + "explode": true }, { - "name": "q", + "name": "code_challenge", "in": "query", - "description": "The free-text search term, 2 to 100 characters after trimming.", + "description": "PKCE code challenge", "required": true, "deprecated": false, + "schema": { + "type": "string" + }, + "style": "form", + "explode": true + }, + { + "name": "code_challenge_method", + "in": "query", + "description": "PKCE code challenge method (S256 or plain, defaults to plain)", + "required": false, + "deprecated": false, "schema": { "type": "string", - "minLength": 2, - "maxLength": 100 + "enum": [ + "S256", + "plain" + ] + }, + "style": "form", + "explode": true + }, + { + "name": "nonce", + "in": "query", + "description": "OIDC nonce value (optional)", + "required": false, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "form", + "explode": true + }, + { + "name": "prompt", + "in": "query", + "description": "OIDC prompt values (space-separated)", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "example": "login consent" + }, + "style": "form", + "explode": true + }, + { + "name": "max_age", + "in": "query", + "description": "Maximum authentication age in seconds", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "example": 3600 }, "style": "form", "explode": true } + ], + "security": [ + { + "bearerAuth": [] + } ] } }, - "/api/organizations/invitations/accept": { - "post": { - "operationId": "acceptOrganizationInvitation", + "/api/oauth2/consent/check": { + "get": { + "operationId": "check_consent", "tags": [ - "Organization Invitations" + "OAuth2" ], "responses": { - "201": { - "description": "OrganizationInvitation resource created", + "200": { + "description": "Consent check result", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/OrganizationInvitation.OrganizationMemberOutput.jsonld-Organization.read" + "$ref": "#/components/schemas/OAuth2.CheckConsentOutput.jsonld-consent.read" } } }, - "links": {} + "links": { + "GetUserInfo": { + "operationId": "userinfo", + "description": "Get user information after consent check" + } + } }, "400": { - "description": "Invalid input", + "description": "Missing required client_id parameter" + }, + "401": { + "description": "Missing or invalid access token" + }, + "404": { + "description": "Not found", "content": { "application/ld+json": { "schema": { @@ -35517,8 +37677,64 @@ }, "links": {} }, - "403": { - "description": "Forbidden", + "429": { + "description": "Too many consent checks" + } + }, + "summary": "Check User Consent", + "description": "Check if the authenticated user has granted consent for a specific client and scopes.", + "parameters": [ + { + "name": "client_id", + "in": "query", + "description": "The OAuth2 client identifier requesting authorization", + "required": true, + "deprecated": false, + "schema": { + "type": "string", + "format": "uuid", + "example": "01234567-89ab-cdef-0123-456789abcdef" + }, + "style": "form", + "explode": true + }, + { + "name": "scope", + "in": "query", + "description": "Space-separated list of requested OAuth2 scopes", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "example": "openid profile email" + }, + "style": "form", + "explode": true + } + ], + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/api/oauth2/consent/grant": { + "post": { + "operationId": "grant_consent", + "tags": [ + "OAuth2" + ], + "responses": { + "204": { + "description": "OAuth2 resource created", + "links": {} + }, + "302": { + "description": "Authorization completed (redirect to redirect_uri)." + }, + "400": { + "description": "Invalid consent request.", "content": { "application/ld+json": { "schema": { @@ -35538,6 +37754,9 @@ }, "links": {} }, + "401": { + "description": "Authentication required." + }, "422": { "description": "An error occurred", "content": { @@ -35558,61 +37777,60 @@ } }, "links": {} + }, + "429": { + "description": "Too many consent submissions" } }, - "summary": "Accept Organization invitation", - "description": "Accepts an invitation token and creates/updates organization membership for the authenticated user.", + "summary": "Grant User Consent", + "description": "Approve or deny a consent request and complete the authorization flow.", "parameters": [], "requestBody": { - "description": "The new OrganizationInvitation resource", + "description": "The new OAuth2 resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/OrganizationInvitation.AcceptOrganizationInvitationInput-Organization.write" + "$ref": "#/components/schemas/OAuth2.GrantConsentInput-consent.write" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/OAuth2.GrantConsentInput-consent.write" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/OAuth2.GrantConsentInput-consent.write" } } }, "required": true - } + }, + "security": [ + { + "bearerAuth": [] + } + ] } }, - "/api/organizations/invitations/{token}/preview": { + "/api/oauth2/logout": { "get": { - "operationId": "getOrganizationInvitationPreview", + "operationId": "end_session", "tags": [ - "Organization Invitations" + "OAuth2" ], "responses": { "200": { - "description": "OrganizationInvitation resource", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/OrganizationInvitation.OrganizationInvitationPreviewOutput.jsonld-Organization.read" - } - } - } + "description": "Logout completed without redirect." }, - "403": { - "description": "Forbidden", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "links": {} + "204": { + "description": "OAuth2 resource" + }, + "302": { + "description": "Redirect to the post_logout_redirect_uri." + }, + "400": { + "description": "Invalid logout request." }, "404": { "description": "Not found", @@ -35636,63 +37854,111 @@ "links": {} } }, - "summary": "Preview Organization invitation", - "description": "Returns a minimal, public-safe preview of an invitation resolved by token (organization, inviter, invited email, status, expiry, granted role names).", + "summary": "End Session", + "description": "Terminate the current OpenID Connect session and optionally redirect to a client post-logout URI.", "parameters": [ { - "name": "token", - "in": "path", - "description": "OrganizationInvitation identifier", - "required": true, + "name": "id_token_hint", + "in": "query", + "description": "ID token previously issued by this server (optional)", + "required": false, "deprecated": false, "schema": { "type": "string" }, - "style": "simple", - "explode": false + "style": "form", + "explode": true + }, + { + "name": "post_logout_redirect_uri", + "in": "query", + "description": "Client post-logout redirect URI (must be registered)", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "uri" + }, + "style": "form", + "explode": true + }, + { + "name": "client_id", + "in": "query", + "description": "OAuth2 client identifier (required when post_logout_redirect_uri is provided)", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "uuid" + }, + "style": "form", + "explode": true + }, + { + "name": "state", + "in": "query", + "description": "Opaque state value returned to the client after logout", + "required": false, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "form", + "explode": true } ] } }, - "/api/organizations/{organizationId}/invitations": { - "get": { - "operationId": "listOrganizationInvitations", + "/api/oauth2/token": { + "post": { + "operationId": "token", "tags": [ - "Organization Invitations" + "OAuth2" ], "responses": { "200": { - "description": "OrganizationInvitation collection", + "description": "Access token issued successfully", "content": { "application/ld+json": { "schema": { - "type": "object", - "description": "OrganizationInvitation.OrganizationInvitationOutput.jsonld-Organization.read collection.", - "allOf": [ - { - "$ref": "#/components/schemas/HydraCollectionBaseSchema" - }, - { - "type": "object", - "required": [ - "member" - ], - "properties": { - "member": { - "type": "array", - "items": { - "$ref": "#/components/schemas/OrganizationInvitation.OrganizationInvitationOutput.jsonld-Organization.read" - } - } - } - } - ] + "$ref": "#/components/schemas/OAuth2.TokenOutput.jsonld-token.read" + } + } + }, + "links": { + "IntrospectToken": { + "operationId": "introspect_token", + "description": "Introspect the issued token to get its metadata", + "parameters": { + "token": "$response.body#/access_token", + "token_type_hint": "access_token" + } + }, + "RevokeToken": { + "operationId": "revoke_token", + "description": "Revoke the issued token when no longer needed", + "parameters": { + "token": "$response.body#/access_token", + "token_type_hint": "access_token" } + }, + "RevokeRefreshToken": { + "operationId": "revoke_token", + "description": "Revoke the refresh token when no longer needed", + "parameters": { + "token": "$response.body#/refresh_token", + "token_type_hint": "refresh_token" + } + }, + "GetUserInfo": { + "operationId": "userinfo", + "description": "Get user information using the access token (pass as Bearer header)" } } }, - "403": { - "description": "Forbidden", + "400": { + "description": "Invalid request - missing or invalid parameters", "content": { "application/ld+json": { "schema": { @@ -35711,69 +37977,87 @@ } }, "links": {} + }, + "401": { + "description": "Invalid client credentials or unauthorized grant type" + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + }, + "429": { + "description": "Too many token requests" } }, - "summary": "List Organization invitations", - "description": "Lists invitations created for an Organization.", - "parameters": [ - { - "name": "organizationId", - "in": "path", - "description": "OrganizationInvitation identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" + "summary": "Issue Access Token", + "description": "Issue an access token using configured OAuth2 grant types (see discovery metadata).", + "parameters": [], + "requestBody": { + "description": "The new OAuth2 resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/OAuth2.TokenInput-token.write" + } }, - "style": "simple", - "explode": false - }, - { - "name": "page", - "in": "query", - "description": "The collection page number", - "required": false, - "deprecated": false, - "schema": { - "type": "integer", - "default": 1 + "application/json": { + "schema": { + "$ref": "#/components/schemas/OAuth2.TokenInput-token.write" + } }, - "style": "form", - "explode": true + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/OAuth2.TokenInput-token.write" + } + } }, - { - "name": "itemsPerPage", - "in": "query", - "description": "The number of items per page", - "required": false, - "deprecated": false, - "schema": { - "type": "integer", - "default": 30, - "minimum": 0, - "maximum": 100 - }, - "style": "form", - "explode": true - } - ] - }, + "required": true + } + } + }, + "/api/oauth2/token/introspect": { "post": { - "operationId": "inviteOrganizationMember", + "operationId": "introspect_token", "tags": [ - "Organization Invitations" + "OAuth2" ], "responses": { - "201": { - "description": "OrganizationInvitation resource created", + "200": { + "description": "Token introspection result", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/OrganizationInvitation.OrganizationInvitationOutput.jsonld-Organization.read" + "$ref": "#/components/schemas/OAuth2.TokenIntrospectionOutput.jsonld-token.read" } } }, - "links": {} + "links": { + "RevokeToken": { + "operationId": "revoke_token", + "description": "Revoke the token if it should be invalidated", + "parameters": { + "token": "$request.body#/token", + "token_type_hint": "$request.body#/token_type_hint" + } + } + } }, "400": { "description": "Invalid input", @@ -35796,6 +38080,9 @@ }, "links": {} }, + "401": { + "description": "Invalid client credentials" + }, "403": { "description": "Forbidden", "content": { @@ -35837,57 +38124,60 @@ } }, "links": {} + }, + "429": { + "description": "Too many introspection requests" } }, - "summary": "Invite Organization member", - "description": "Invites a user by email to join an Organization with one or more roles.", - "parameters": [ - { - "name": "organizationId", - "in": "path", - "description": "OrganizationInvitation identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - } - ], + "summary": "Introspect Token", + "description": "Query the authorization server about the current state and metadata of a token as defined in RFC 7662.", + "parameters": [], "requestBody": { - "description": "The new OrganizationInvitation resource", + "description": "The new OAuth2 resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/OrganizationInvitation.InviteOrganizationMemberInput-Organization.write" + "$ref": "#/components/schemas/OAuth2.TokenIntrospectionInput-token.write" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/OAuth2.TokenIntrospectionInput-token.write" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/OAuth2.TokenIntrospectionInput-token.write" } } }, "required": true - } + }, + "security": [ + { + "bearerAuth": [] + } + ] } }, - "/api/organizations/{organizationId}/invitations/{invitationId}/resend": { + "/api/oauth2/token/revoke": { "post": { - "operationId": "resendOrganizationInvitation", + "operationId": "revoke_token", "tags": [ - "Organization Invitations" + "OAuth2" ], "responses": { "200": { - "description": "OrganizationInvitation resource created", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/OrganizationInvitation.OrganizationInvitationOutput.jsonld-Organization.read" - } + "description": "Token revoked successfully", + "links": { + "IssueNewToken": { + "operationId": "token", + "description": "Issue a new token after revoking the old one" } - }, - "links": {} + } }, "400": { - "description": "Invalid input", + "description": "Invalid request - missing token parameter", "content": { "application/ld+json": { "schema": { @@ -35907,6 +38197,9 @@ }, "links": {} }, + "401": { + "description": "Invalid client credentials" + }, "403": { "description": "Forbidden", "content": { @@ -35948,58 +38241,145 @@ } }, "links": {} + }, + "429": { + "description": "Too many revocation requests" } }, - "summary": "Resend Organization invitation", - "description": "Regenerates the token, resets the expiry and re-sends the invitation email, returning a fresh accept link.", - "parameters": [ - { - "name": "organizationId", - "in": "path", - "description": "OrganizationInvitation identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" + "summary": "Revoke Token", + "description": "Revoke an access token or refresh token as defined in RFC 7009.", + "parameters": [], + "requestBody": { + "description": "The new OAuth2 resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/OAuth2.TokenRevocationInput" + } }, - "style": "simple", - "explode": false + "application/json": { + "schema": { + "$ref": "#/components/schemas/OAuth2.TokenRevocationInput" + } + }, + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/OAuth2.TokenRevocationInput" + } + } }, + "required": true + }, + "security": [ { - "name": "invitationId", - "in": "path", - "description": "OrganizationInvitation identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false + "bearerAuth": [] } ] } }, - "/api/organizations/{organizationId}/invitations/{invitationId}/revoke": { - "post": { - "operationId": "revokeOrganizationInvitation", + "/api/oauth2/userinfo": { + "get": { + "operationId": "userinfo", "tags": [ - "Organization Invitations" + "OAuth2" ], "responses": { "200": { - "description": "OrganizationInvitation resource created", + "description": "User information retrieved successfully", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/OrganizationInvitation.OrganizationInvitationOutput.jsonld-Organization.read" + "$ref": "#/components/schemas/OAuth2.UserInfoOutput.jsonld-token.read" + } + } + }, + "links": { + "CheckConsent": { + "operationId": "check_consent", + "description": "Check user consent status for a client" + } + } + }, + "401": { + "description": "Missing or invalid access token" + }, + "403": { + "description": "Access token lacks required scopes" + }, + "404": { + "description": "Not found", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" } } }, "links": {} + } + }, + "summary": "Get User Information", + "description": "Retrieve claims about the authenticated user as defined by OpenID Connect Core 1.0.", + "parameters": [], + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/api/otp/challenges": { + "post": { + "operationId": "createChallenge", + "tags": [ + "OTP" + ], + "responses": { + "201": { + "description": "OTP challenge created successfully", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Challenge.ChallengeOutput.jsonld" + } + } + }, + "links": { + "GetChallengeStatus": { + "operationId": "getChallengeStatus", + "description": "Check the challenge status", + "parameters": { + "token": "$response.body#/token" + } + }, + "VerifyChallenge": { + "operationId": "verifyChallenge", + "description": "Verify the OTP code", + "parameters": { + "token": "$response.body#/token" + } + }, + "ResendChallenge": { + "operationId": "resendChallenge", + "description": "Resend the OTP if canResendIn is 0", + "parameters": { + "token": "$response.body#/token" + } + } + } }, "400": { - "description": "Invalid input", + "description": "Invalid request parameters", "content": { "application/ld+json": { "schema": { @@ -36060,58 +38440,59 @@ } }, "links": {} + }, + "429": { + "description": "Rate limit exceeded" } }, - "summary": "Revoke Organization invitation", - "description": "Revokes a pending invitation.", - "parameters": [ - { - "name": "organizationId", - "in": "path", - "description": "OrganizationInvitation identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false + "summary": "Create OTP challenge", + "description": "Creates a new OTP challenge and sends the code via the specified channel. Requires otp_challenges.create permission.", + "parameters": [], + "requestBody": { + "description": "The new Challenge resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Challenge.CreateChallengeInput" + } + } }, + "required": true + }, + "security": [ { - "name": "invitationId", - "in": "path", - "description": "OrganizationInvitation identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false + "bearerAuth": [] } ] } }, - "/api/organizations/{organizationId}/me": { + "/api/otp/challenges/{token}": { "get": { - "operationId": "getCurrentOrganizationMemberProfile", + "operationId": "getChallengeStatus", "tags": [ - "Organization Members" + "OTP" ], "responses": { "200": { - "description": "Current organization member profile retrieved successfully", + "description": "Challenge status retrieved", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/CurrentOrganizationMemberProfile.CurrentOrganizationMemberProfileOutput.jsonld-Organization.read" + "$ref": "#/components/schemas/Challenge.ChallengeOutput.jsonld" } } + }, + "links": { + "VerifyChallenge": { + "operationId": "verifyChallenge", + "description": "Verify the OTP code if status is pending" + }, + "ResendChallenge": { + "operationId": "resendChallenge", + "description": "Resend the OTP if canResendIn is 0" + } } }, - "401": { - "description": "Authentication required" - }, "403": { "description": "Forbidden", "content": { @@ -36134,16 +38515,16 @@ "links": {} }, "404": { - "description": "Organization not found or the authenticated user is not an active member" + "description": "Challenge not found" } }, - "summary": "Get current organization member profile", - "description": "Returns the authenticated active member profile in the target organization, including resolved roles and effective permissions.", + "summary": "Get challenge status", + "description": "Returns the current status of an OTP challenge. Requires otp_challenges.read permission.", "parameters": [ { - "name": "organizationId", + "name": "token", "in": "path", - "description": "CurrentOrganizationMemberProfile identifier", + "description": "Challenge identifier", "required": true, "deprecated": false, "schema": { @@ -36160,42 +38541,44 @@ ] } }, - "/api/organizations/{organizationId}/members": { - "get": { - "operationId": "listOrganizationMembers", + "/api/otp/challenges/{token}/resend": { + "post": { + "operationId": "resendChallenge", "tags": [ - "Organization Members" + "OTP" ], "responses": { "200": { - "description": "OrganizationMember collection", + "description": "OTP resent successfully", "content": { "application/ld+json": { "schema": { - "type": "object", - "description": "OrganizationMember.OrganizationMemberOutput.jsonld-Organization.read collection.", - "allOf": [ - { - "$ref": "#/components/schemas/HydraCollectionBaseSchema" - }, - { - "type": "object", - "required": [ - "member" - ], - "properties": { - "member": { - "type": "array", - "items": { - "$ref": "#/components/schemas/OrganizationMember.OrganizationMemberOutput.jsonld-Organization.read" - } - } - } - } - ] + "$ref": "#/components/schemas/Challenge.ChallengeOutput.jsonld" } } - } + }, + "links": {} + }, + "400": { + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} }, "403": { "description": "Forbidden", @@ -36217,15 +38600,42 @@ } }, "links": {} - } + }, + "404": { + "description": "Challenge not found" + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + }, + "429": { + "description": "Resend cooldown not yet elapsed" + } }, - "summary": "List Organization members", - "description": "Lists Organization members and assigned roles. Supports `search` (matched against the member's user identifier), `status` (`active`, `inactive`, or `all`), `roleId`, and `order[joinedAt|displayName]=asc|desc` (default `order[joinedAt]=asc`).", + "summary": "Resend OTP", + "description": "Resends the OTP code. Subject to cooldown period. Requires otp_challenges.resend permission.", "parameters": [ { - "name": "organizationId", + "name": "token", "in": "path", - "description": "OrganizationMember identifier", + "description": "Challenge identifier", "required": true, "deprecated": false, "schema": { @@ -36233,56 +38643,35 @@ }, "style": "simple", "explode": false - }, - { - "name": "page", - "in": "query", - "description": "The collection page number", - "required": false, - "deprecated": false, - "schema": { - "type": "integer", - "default": 1 - }, - "style": "form", - "explode": true - }, + } + ], + "security": [ { - "name": "itemsPerPage", - "in": "query", - "description": "The number of items per page", - "required": false, - "deprecated": false, - "schema": { - "type": "integer", - "default": 30, - "minimum": 0, - "maximum": 100 - }, - "style": "form", - "explode": true + "bearerAuth": [] } ] - }, + } + }, + "/api/otp/challenges/{token}/verify": { "post": { - "operationId": "addOrganizationMember", + "operationId": "verifyChallenge", "tags": [ - "Organization Members" + "OTP" ], "responses": { - "201": { - "description": "OrganizationMember resource created", + "200": { + "description": "OTP verified successfully", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/OrganizationMember.OrganizationMemberOutput.jsonld-Organization.read" + "$ref": "#/components/schemas/Challenge.VerifyOtpOutput.jsonld" } } }, "links": {} }, "400": { - "description": "Invalid input", + "description": "Invalid OTP code", "content": { "application/ld+json": { "schema": { @@ -36323,6 +38712,12 @@ }, "links": {} }, + "404": { + "description": "Challenge not found" + }, + "410": { + "description": "Challenge expired or max attempts reached" + }, "422": { "description": "An error occurred", "content": { @@ -36343,15 +38738,18 @@ } }, "links": {} + }, + "429": { + "description": "Too many OTP verification attempts" } }, - "summary": "Add Organization member", - "description": "Adds an existing user to a Organization and assigns one or more roles.", + "summary": "Verify challenge", + "description": "Verifies the OTP code for a challenge. Requires otp_challenges.verify permission.", "parameters": [ { - "name": "organizationId", + "name": "token", "in": "path", - "description": "OrganizationMember identifier", + "description": "Challenge identifier", "required": true, "deprecated": false, "schema": { @@ -36362,56 +38760,45 @@ } ], "requestBody": { - "description": "The new OrganizationMember resource", + "description": "The new Challenge resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/OrganizationMember.AddOrganizationMemberInput-Organization.write" + "$ref": "#/components/schemas/Challenge.VerifyOtpInput" } } }, "required": true - } + }, + "security": [ + { + "bearerAuth": [] + } + ] } }, - "/api/organizations/{organizationId}/members/batch-remove": { - "post": { - "operationId": "batchRemoveOrganizationMembers", + "/api/otp/channels": { + "get": { + "operationId": "listChannels", "tags": [ - "Organization Members" + "OTP" ], "responses": { "200": { - "description": "OrganizationMember resource created", + "description": "List of available OTP channels", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/OrganizationMember.RemoveOrganizationMembersOutput.jsonld-Organization.read" + "$ref": "#/components/schemas/OtpConfig.Config.ChannelOutput.jsonld" } } }, - "links": {} - }, - "400": { - "description": "Invalid input", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } + "links": { + "CreateChallenge": { + "operationId": "createChallenge", + "description": "Create a new OTP challenge using one of these channels" } - }, - "links": {} + } }, "403": { "description": "Forbidden", @@ -36434,66 +38821,60 @@ }, "links": {} }, - "422": { - "description": "An error occurred", + "404": { + "description": "Not found", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation.jsonld" + "$ref": "#/components/schemas/Error.jsonld" } }, "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation" + "$ref": "#/components/schemas/Error" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation" + "$ref": "#/components/schemas/Error" } } }, "links": {} } }, - "summary": "Batch remove Organization members", - "description": "Removes several members in one request, reporting removed and failed IDs.", - "parameters": [ + "summary": "List OTP channels", + "description": "Returns all available OTP delivery channels. Requires otp_config.read permission.", + "parameters": [], + "security": [ { - "name": "organizationId", - "in": "path", - "description": "OrganizationMember identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false + "bearerAuth": [] } - ], - "requestBody": { - "description": "The new OrganizationMember resource", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/OrganizationMember.RemoveOrganizationMembersInput-Organization.write" - } - } - }, - "required": true - } + ] } }, - "/api/organizations/{organizationId}/members/me": { - "delete": { - "operationId": "leaveOrganization", + "/api/otp/purposes": { + "get": { + "operationId": "listPurposes", "tags": [ - "Organization Members" + "OTP" ], "responses": { - "204": { - "description": "Membership deactivated" + "200": { + "description": "List of available OTP purposes", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/OtpConfig.PurposeOutput.jsonld" + } + } + }, + "links": { + "CreateChallenge": { + "operationId": "createChallenge", + "description": "Create a new OTP challenge using one of these purposes" + } + } }, "403": { "description": "Forbidden", @@ -36517,145 +38898,50 @@ "links": {} }, "404": { - "description": "Organization not found, or caller is not an active member" - }, - "409": { - "description": "Caller is the organization's current owner, or is the last active administrator" - } - }, - "summary": "Leave Organization", - "description": "Deactivates the authenticated user's own membership (self-removal). The organization's current owner cannot leave — transfer ownership first via POST /organizations/{id}/transfer-ownership. Refused when leaving would strip the organization of its last active administrator.", - "parameters": [ - { - "name": "organizationId", - "in": "path", - "description": "OrganizationMember identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - } - ] - } - }, - "/api/organizations/{organizationId}/members/{memberId}": { - "get": { - "operationId": "getOrganizationMember", - "tags": [ - "Organization Members" - ], - "responses": { - "200": { - "description": "OrganizationMember resource", + "description": "Not found", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/OrganizationMember.OrganizationMemberOutput.jsonld-Organization.read" + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" } } - } - }, - "403": { - "description": "Insufficient permissions" - }, - "404": { - "description": "Organization not found, or member not found in this organization" - } - }, - "summary": "Get Organization member", - "description": "Resolves a single organization member by identifier.", - "parameters": [ - { - "name": "organizationId", - "in": "path", - "description": "OrganizationMember identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - }, - { - "name": "memberId", - "in": "path", - "description": "OrganizationMember identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" }, - "style": "simple", - "explode": false - } - ] - }, - "delete": { - "operationId": "removeOrganizationMember", - "tags": [ - "Organization Members" - ], - "responses": { - "204": { - "description": "Member removed" - }, - "400": { - "description": "Invalid identifier" - }, - "403": { - "description": "Insufficient permissions" - }, - "404": { - "description": "Organization or member not found" + "links": {} } }, - "summary": "Remove Organization member", - "description": "Deactivates an organization member. The membership record is retained for audit purposes.", - "parameters": [ - { - "name": "organizationId", - "in": "path", - "description": "OrganizationMember identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - }, + "summary": "List OTP purposes", + "description": "Returns all available OTP purposes with their default TTL and max attempts configuration. Requires otp_config.read permission.", + "parameters": [], + "security": [ { - "name": "memberId", - "in": "path", - "description": "OrganizationMember identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false + "bearerAuth": [] } ] } }, - "/api/organizations/{organizationId}/members/{memberId}/reactivate": { + "/api/otp/totp/confirm": { "post": { - "operationId": "reactivateOrganizationMember", + "operationId": "confirmTotp", "tags": [ - "Organization Members" + "OTP" ], "responses": { "200": { - "description": "Member reactivated", + "description": "TOTP activated successfully", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/OrganizationMember.OrganizationMemberOutput.jsonld-Organization.read" + "$ref": "#/components/schemas/Totp.ConfirmTotpOutput.jsonld" } } }, @@ -36683,169 +38969,91 @@ "links": {} }, "403": { - "description": "Insufficient permissions" - }, - "404": { - "description": "Organization not found, or member not found in this organization" - }, - "409": { - "description": "Organization is archived, member is already active, or the plan's member cap has been reached" - }, - "422": { - "description": "An error occurred", + "description": "Forbidden", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation.jsonld" + "$ref": "#/components/schemas/Error.jsonld" } }, "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation" + "$ref": "#/components/schemas/Error" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation" + "$ref": "#/components/schemas/Error" } } }, "links": {} - } - }, - "summary": "Reactivate Organization member", - "description": "Reactivates a previously deactivated (removed) organization member. Subject to the same plan member cap as adding a new member.", - "parameters": [ - { - "name": "organizationId", - "in": "path", - "description": "OrganizationMember identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false }, - { - "name": "memberId", - "in": "path", - "description": "OrganizationMember identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - } - ] - } - }, - "/api/organizations/{organizationId}/permissions": { - "get": { - "operationId": "listOrganizationPermissions", - "tags": [ - "Organization Permissions" - ], - "responses": { - "200": { - "description": "OrganizationPermission collection", - "content": { - "application/ld+json": { - "schema": { - "type": "object", - "description": "OrganizationPermission.OrganizationPermissionOutput.jsonld-Organization.read collection.", - "allOf": [ - { - "$ref": "#/components/schemas/HydraCollectionBaseSchema" - }, - { - "type": "object", - "required": [ - "member" - ], - "properties": { - "member": { - "type": "array", - "items": { - "$ref": "#/components/schemas/OrganizationPermission.OrganizationPermissionOutput.jsonld-Organization.read" - } - } - } - } - ] - } - } - } + "404": { + "description": "No pending TOTP setup found; call setup first" }, - "403": { - "description": "Forbidden", + "409": { + "description": "An active authenticator cannot be replaced by confirming a pending secret" + }, + "422": { + "description": "Invalid or expired verification code", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Error.jsonld" + "$ref": "#/components/schemas/ConstraintViolation.jsonld" } }, "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/ConstraintViolation" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/ConstraintViolation" } } }, "links": {} + }, + "429": { + "description": "Too many confirmation attempts" } }, - "summary": "List available Organization permissions", - "description": "Lists all system-defined permissions that can be assigned to organization roles.", - "parameters": [ - { - "name": "organizationId", - "in": "path", - "description": "OrganizationPermission identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false + "summary": "Confirm TOTP", + "description": "Verifies a code from the authenticator app against the pending secret and, on success, activates TOTP for the user. An existing active factor cannot be replaced, including when a legacy pending secret exists. Requires otp_totp.confirm permission.", + "parameters": [], + "requestBody": { + "description": "The new Totp resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Totp.ConfirmTotpInput" + } + } }, + "required": true + }, + "security": [ { - "name": "page", - "in": "query", - "description": "The collection page number", - "required": false, - "deprecated": false, - "schema": { - "type": "integer", - "default": 1 - }, - "style": "form", - "explode": true + "bearerAuth": [] } ] } }, - "/api/organizations/{organizationId}/members/{memberId}/roles": { - "put": { - "operationId": "setOrganizationMemberRoles", + "/api/otp/totp/disable": { + "post": { + "operationId": "disableTotp", "tags": [ - "Organization Roles" + "OTP" ], "responses": { "200": { - "description": "Member roles replaced", + "description": "TOTP disabled successfully", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/OrganizationRole.OrganizationMemberOutput.jsonld-Organization.read" + "$ref": "#/components/schemas/Totp.DisableTotpOutput.jsonld" } } }, @@ -36873,16 +39081,31 @@ "links": {} }, "403": { - "description": "Insufficient permissions, or attempting to grant a permission the caller does not hold" - }, - "404": { - "description": "Organization, member, or one of the requested roles not found" + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} }, - "409": { - "description": "Would strip the organization of its last administrator" + "404": { + "description": "TOTP is not currently enabled for the user" }, "422": { - "description": "An error occurred", + "description": "Invalid verification code", "content": { "application/ld+json": { "schema": { @@ -36901,64 +39124,54 @@ } }, "links": {} - } - }, - "summary": "Replace member roles", - "description": "Replaces the member's entire role set in one call. Roles being granted go through the privilege-escalation guard; roles being revoked go through the last-administrator lockout guard.", - "parameters": [ - { - "name": "organizationId", - "in": "path", - "description": "OrganizationRole identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false }, - { - "name": "memberId", - "in": "path", - "description": "OrganizationRole identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false + "429": { + "description": "Too many disable attempts" } - ], + }, + "summary": "Disable TOTP", + "description": "Disables TOTP for the authenticated user. Requires a valid current TOTP code as proof of possession. Requires otp_totp.disable permission.", + "parameters": [], "requestBody": { - "description": "The updated OrganizationRole resource", + "description": "The new Totp resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/OrganizationRole.SetOrganizationMemberRolesInput-Organization.write" + "$ref": "#/components/schemas/Totp.DisableTotpInput" } } }, "required": true - } - }, + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/api/otp/totp/setup": { "post": { - "operationId": "assignOrganizationRoleToMember", + "operationId": "setupTotp", "tags": [ - "Organization Roles" + "OTP" ], "responses": { "201": { - "description": "OrganizationRole resource created", + "description": "TOTP setup data returned", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/OrganizationRole.OrganizationMemberOutput.jsonld-Organization.read" + "$ref": "#/components/schemas/Totp.SetupTotpOutput.jsonld" } } }, - "links": {} + "links": { + "ConfirmTotp": { + "operationId": "confirmTotp", + "description": "Confirm the pending secret with a code from the authenticator app" + } + } }, "400": { "description": "Invalid input", @@ -36981,8 +39194,11 @@ }, "links": {} }, + "401": { + "description": "User must be authenticated" + }, "403": { - "description": "Forbidden", + "description": "Authenticator enrollment is unavailable for this account", "content": { "application/ld+json": { "schema": { @@ -36999,8 +39215,10 @@ "$ref": "#/components/schemas/Error" } } - }, - "links": {} + } + }, + "409": { + "description": "An active authenticator must first be disabled with its current code" }, "422": { "description": "An error occurred", @@ -37024,146 +39242,36 @@ "links": {} } }, - "summary": "Assign role to member", - "description": "Assigns an existing Organization role to an existing Organization member.", - "parameters": [ - { - "name": "organizationId", - "in": "path", - "description": "OrganizationRole identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - }, - { - "name": "memberId", - "in": "path", - "description": "OrganizationRole identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - } - ], - "requestBody": { - "description": "The new OrganizationRole resource", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/OrganizationRole.AssignOrganizationRoleInput-Organization.write" - } - } - }, - "required": true - } - } - }, - "/api/organizations/{organizationId}/members/{memberId}/roles/{roleId}": { - "delete": { - "operationId": "removeOrganizationRoleFromMember", - "tags": [ - "Organization Roles" - ], - "responses": { - "204": { - "description": "Role removed from member" - }, - "400": { - "description": "Invalid identifier" - }, - "403": { - "description": "Insufficient permissions" - }, - "404": { - "description": "Organization, member, or role not found" - } - }, - "summary": "Remove role from member", - "description": "Removes a role assignment from an organization member.", - "parameters": [ - { - "name": "organizationId", - "in": "path", - "description": "OrganizationRole identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - }, - { - "name": "memberId", - "in": "path", - "description": "OrganizationRole identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - }, + "summary": "Setup TOTP", + "description": "Generates a new TOTP secret and stores it server-side as a PENDING enrollment for the authenticated user. Returns the secret and a QR code URI for authenticator app setup. Calling this again replaces the pending secret during initial enrollment. Enrollment requires the account to remain active when its serialized mutation starts. An active factor must first be disabled with its current code; setup refuses replacement. Requires otp_totp.setup permission.", + "parameters": [], + "security": [ { - "name": "roleId", - "in": "path", - "description": "OrganizationRole identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false + "bearerAuth": [] } ] } }, - "/api/organizations/{organizationId}/roles": { + "/api/onboarding/organization": { "get": { - "operationId": "listOrganizationRoles", + "operationId": "getOrganizationOnboarding", "tags": [ - "Organization Roles" + "Onboarding" ], "responses": { "200": { - "description": "OrganizationRole collection", + "description": "Onboarding flow retrieved", "content": { "application/ld+json": { "schema": { - "type": "object", - "description": "OrganizationRole.OrganizationRoleOutput.jsonld-Organization.read collection.", - "allOf": [ - { - "$ref": "#/components/schemas/HydraCollectionBaseSchema" - }, - { - "type": "object", - "required": [ - "member" - ], - "properties": { - "member": { - "type": "array", - "items": { - "$ref": "#/components/schemas/OrganizationRole.OrganizationRoleOutput.jsonld-Organization.read" - } - } - } - } - ] + "$ref": "#/components/schemas/OrganizationOnboarding.OrganizationOnboardingOutput.jsonld-onboarding.read" } } } }, + "401": { + "description": "Authentication required" + }, "403": { "description": "Forbidden", "content": { @@ -37184,65 +39292,47 @@ } }, "links": {} - } - }, - "summary": "List Organization roles", - "description": "Lists all roles defined for a Organization. Real pagination (`page`/`itemsPerPage`, default 30); `totalItems` reflects the count AFTER search filtering. Supports `search` (matched against the role name) and `order[name|isSystem|createdAt]=asc|desc` (default `order[name]=asc`).", - "parameters": [ - { - "name": "organizationId", - "in": "path", - "description": "OrganizationRole identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - }, - { - "name": "page", - "in": "query", - "description": "The collection page number", - "required": false, - "deprecated": false, - "schema": { - "type": "integer", - "default": 1 - }, - "style": "form", - "explode": true }, - { - "name": "itemsPerPage", - "in": "query", - "description": "The number of items per page", - "required": false, - "deprecated": false, - "schema": { - "type": "integer", - "default": 30, - "minimum": 0, - "maximum": 100 + "404": { + "description": "Not found", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } }, - "style": "form", - "explode": true + "links": {} } - ] - }, + }, + "summary": "Get organization onboarding flow", + "description": "Returns persisted onboarding progression for organization setup and exposes actionable endpoints for each step.", + "parameters": [] + } + }, + "/api/onboarding/organization/dismiss": { "post": { - "operationId": "createOrganizationRole", + "operationId": "dismissOrganizationOnboarding", "tags": [ - "Organization Roles" + "Onboarding" ], "responses": { - "201": { - "description": "OrganizationRole resource created", + "200": { + "description": "Onboarding activation dismissed", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/OrganizationRole.OrganizationRoleOutput.jsonld-Organization.read" + "$ref": "#/components/schemas/OrganizationOnboarding.OrganizationOnboardingOutput.jsonld-onboarding.read" } } }, @@ -37269,6 +39359,9 @@ }, "links": {} }, + "401": { + "description": "Authentication required" + }, "403": { "description": "Forbidden", "content": { @@ -37312,155 +39405,55 @@ "links": {} } }, - "summary": "Create Organization role", - "description": "Creates a custom role inside a Organization with explicit permissions.", - "parameters": [ - { - "name": "organizationId", - "in": "path", - "description": "OrganizationRole identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - } - ], - "requestBody": { - "description": "The new OrganizationRole resource", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/OrganizationRole.CreateOrganizationRoleInput-Organization.write" - } - } - }, - "required": true - } + "summary": "Dismiss organization onboarding activation", + "description": "Voluntarily hides the non-blocking activation flow without completing it. Progression is preserved and can be resumed later.", + "parameters": [] } }, - "/api/organizations/{organizationId}/roles/{roleId}": { - "get": { - "operationId": "getOrganizationRole", + "/api/onboarding/organization/resume": { + "post": { + "operationId": "resumeOrganizationOnboarding", "tags": [ - "Organization Roles" + "Onboarding" ], "responses": { "200": { - "description": "Role retrieved", + "description": "Onboarding activation resumed", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/OrganizationRole.OrganizationRoleOutput.jsonld-Organization.read" + "$ref": "#/components/schemas/OrganizationOnboarding.OrganizationOnboardingOutput.jsonld-onboarding.read" } } - } - }, - "403": { - "description": "Insufficient permissions" - }, - "404": { - "description": "Organization or role not found, or the role belongs to another organization" - } - }, - "summary": "Get Organization role", - "description": "Returns a single organization role, including memberCount (the number of ACTIVE members currently assigned). Requires the organization.roles.read permission.", - "parameters": [ - { - "name": "organizationId", - "in": "path", - "description": "OrganizationRole identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - }, - { - "name": "roleId", - "in": "path", - "description": "OrganizationRole identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" }, - "style": "simple", - "explode": false - } - ] - }, - "delete": { - "operationId": "deleteOrganizationRole", - "tags": [ - "Organization Roles" - ], - "responses": { - "204": { - "description": "Role deleted" + "links": {} }, "400": { - "description": "Invalid identifier or system role" - }, - "403": { - "description": "Insufficient permissions" - }, - "404": { - "description": "Organization or role not found" - } - }, - "summary": "Delete Organization role", - "description": "Permanently deletes a custom role. All member role assignments for this role are removed. System roles cannot be deleted.", - "parameters": [ - { - "name": "organizationId", - "in": "path", - "description": "OrganizationRole identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - }, - { - "name": "roleId", - "in": "path", - "description": "OrganizationRole identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - } - ] - }, - "patch": { - "operationId": "updateOrganizationRole", - "tags": [ - "Organization Roles" - ], - "responses": { - "200": { - "description": "Role updated", + "description": "Invalid input", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/OrganizationRole.OrganizationRoleOutput.jsonld-Organization.read" + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" } } }, "links": {} }, - "400": { - "description": "Invalid payload, or the role is a system role", + "401": { + "description": "Authentication required" + }, + "403": { + "description": "Forbidden", "content": { "application/ld+json": { "schema": { @@ -37480,15 +39473,6 @@ }, "links": {} }, - "403": { - "description": "Insufficient permissions, or granting a permission the caller does not hold" - }, - "404": { - "description": "Organization or role not found, or the role belongs to another organization" - }, - "409": { - "description": "The change would leave the organization without an administrator" - }, "422": { "description": "An error occurred", "content": { @@ -37511,60 +39495,24 @@ "links": {} } }, - "summary": "Update Organization role permissions", - "description": "Updates the permissions assigned to a custom organization role, and optionally renames it.", - "parameters": [ - { - "name": "organizationId", - "in": "path", - "description": "OrganizationRole identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - }, - { - "name": "roleId", - "in": "path", - "description": "OrganizationRole identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - } - ], - "requestBody": { - "description": "The updated OrganizationRole resource", - "content": { - "application/merge-patch+json": { - "schema": { - "$ref": "#/components/schemas/OrganizationRole.UpdateOrganizationRoleInput-Organization.write.jsonMergePatch" - } - } - }, - "required": true - } + "summary": "Resume organization onboarding activation", + "description": "Clears a previous dismissal so the activation flow and setup checklist become visible again.", + "parameters": [] } }, - "/api/organizations/invitations/{invitationId}/accept": { + "/api/onboarding/organization/rollback": { "post": { - "operationId": "organization_join_accept_invitation_by_id", + "operationId": "rollbackOrganizationOnboarding", "tags": [ - "Organization access" + "Onboarding" ], "responses": { "200": { - "description": "OrganizationJoin resource created", + "description": "Onboarding step rolled back", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/OrganizationJoin.OrganizationJoinCompletedOutput.jsonld-Organization.read" + "$ref": "#/components/schemas/OrganizationOnboarding.OrganizationOnboardingOutput.jsonld-onboarding.read" } } }, @@ -37591,6 +39539,9 @@ }, "links": {} }, + "401": { + "description": "Authentication required" + }, "403": { "description": "Forbidden", "content": { @@ -37612,6 +39563,9 @@ }, "links": {} }, + "409": { + "description": "No rollback action available" + }, "422": { "description": "An error occurred", "content": { @@ -37634,64 +39588,31 @@ "links": {} } }, - "summary": "accept invitation by id", - "description": "accept invitation by id", - "parameters": [ - { - "name": "invitationId", - "in": "path", - "description": "OrganizationJoin identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - } - ] + "summary": "Rollback last organization onboarding step", + "description": "Rolls back the last rollbackable onboarding step for the authenticated user.", + "parameters": [] } }, - "/api/organizations/join-options": { - "get": { - "operationId": "organization_join_join_options", + "/api/onboarding/organization/setup-operations": { + "post": { + "operationId": "prepareOrganizationSetup", "tags": [ - "Organization access" + "Onboarding" ], "responses": { "200": { - "description": "OrganizationJoin resource", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/OrganizationJoin.OrganizationJoinOptionsOutput.jsonld-Organization.read" - } - } - } - }, - "403": { - "description": "Forbidden", + "description": "Batch prepared or already prepared", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/OrganizationOnboarding.OrganizationOnboardingOutput.jsonld-onboarding.read" } } }, "links": {} }, - "404": { - "description": "Not found", + "400": { + "description": "Invalid input", "content": { "application/ld+json": { "schema": { @@ -37710,29 +39631,9 @@ } }, "links": {} - } - }, - "summary": "join options", - "description": "join options", - "parameters": [] - } - }, - "/api/organizations/join-requests": { - "get": { - "operationId": "organization_join_my_requests", - "tags": [ - "Organization access" - ], - "responses": { - "200": { - "description": "OrganizationJoin resource", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/OrganizationJoin.OrganizationJoinRequestsOutput.jsonld-Organization.read" - } - } - } + }, + "401": { + "description": "Authentication required" }, "403": { "description": "Forbidden", @@ -37755,46 +39656,60 @@ }, "links": {} }, - "404": { - "description": "Not found", + "409": { + "description": "Session, step, item or payload conflict" + }, + "422": { + "description": "An error occurred", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Error.jsonld" + "$ref": "#/components/schemas/ConstraintViolation.jsonld" } }, "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/ConstraintViolation" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/ConstraintViolation" } } }, "links": {} } }, - "summary": "my requests", - "description": "my requests", - "parameters": [] + "summary": "Prepare a durable setup batch", + "description": "Persists up to five item inputs for the current creator step. Repeating a key requires identical input. Owner resource POSTs consume the returned session and item keys atomically.", + "parameters": [], + "requestBody": { + "description": "The new OrganizationOnboarding resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/OrganizationOnboarding.PrepareOrganizationSetupInput-onboarding.write" + } + } + }, + "required": true + } } }, - "/api/organizations/join-requests/{requestId}/cancel": { + "/api/onboarding/organization/start": { "post": { - "operationId": "organization_join_cancel_request", + "operationId": "startOrganizationOnboarding", "tags": [ - "Organization access" + "Onboarding" ], "responses": { "200": { - "description": "OrganizationJoin resource created", + "description": "Onboarding flow started", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/OrganizationJoin.OrganizationJoinRequestOutput.jsonld-Organization.read" + "$ref": "#/components/schemas/OrganizationOnboarding.OrganizationOnboardingOutput.jsonld-onboarding.read" } } }, @@ -37821,6 +39736,9 @@ }, "links": {} }, + "401": { + "description": "Authentication required" + }, "403": { "description": "Forbidden", "content": { @@ -37864,120 +39782,42 @@ "links": {} } }, - "summary": "cancel request", - "description": "cancel request", - "parameters": [ - { - "name": "requestId", - "in": "path", - "description": "OrganizationJoin identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - } - ] - } - }, - "/api/organizations/{organizationId}/access-policy": { - "get": { - "operationId": "organization_join_access_policy", - "tags": [ - "Organization access" - ], - "responses": { - "200": { - "description": "OrganizationJoin resource", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/OrganizationJoin.OrganizationAccessPolicyOutput.jsonld-Organization.read" - } + "summary": "Start organization onboarding flow", + "description": "Starts or resets the persisted organization onboarding session for the authenticated user.", + "parameters": [], + "requestBody": { + "description": "The new OrganizationOnboarding resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/OrganizationOnboarding.StartOrganizationOnboardingInput-onboarding.write" } } }, - "403": { - "description": "Forbidden", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "links": {} - }, - "404": { - "description": "Not found", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "links": {} - } - }, - "summary": "access policy", - "description": "access policy", - "parameters": [ - { - "name": "organizationId", - "in": "path", - "description": "OrganizationJoin identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - } - ] - }, - "patch": { - "operationId": "organization_join_update_access_policy", + "required": true + } + } + }, + "/api/onboarding/organization/steps/{stepKey}/execute": { + "post": { + "operationId": "executeOrganizationOnboardingStep", "tags": [ - "Organization access" + "Onboarding" ], "responses": { "200": { - "description": "OrganizationJoin resource updated", + "description": "Onboarding step executed", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/OrganizationJoin.OrganizationAccessPolicyOutput.jsonld-Organization.read" + "$ref": "#/components/schemas/OrganizationOnboarding.OrganizationOnboardingOutput.jsonld-onboarding.read" } } }, "links": {} }, "400": { - "description": "Invalid input", + "description": "Invalid step payload", "content": { "application/ld+json": { "schema": { @@ -37997,6 +39837,9 @@ }, "links": {} }, + "401": { + "description": "Authentication required" + }, "403": { "description": "Forbidden", "content": { @@ -38018,26 +39861,8 @@ }, "links": {} }, - "404": { - "description": "Not found", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "links": {} + "409": { + "description": "Step not available in current flow state" }, "422": { "description": "An error occurred", @@ -38061,13 +39886,13 @@ "links": {} } }, - "summary": "update access policy", - "description": "update access policy", + "summary": "Execute organization onboarding step", + "description": "Executes the requested onboarding step and updates persisted flow state.", "parameters": [ { - "name": "organizationId", + "name": "stepKey", "in": "path", - "description": "OrganizationJoin identifier", + "description": "OrganizationOnboarding identifier", "required": true, "deprecated": false, "schema": { @@ -38078,11 +39903,11 @@ } ], "requestBody": { - "description": "The updated OrganizationJoin resource", + "description": "The new OrganizationOnboarding resource", "content": { - "application/merge-patch+json": { + "application/ld+json": { "schema": { - "$ref": "#/components/schemas/OrganizationJoin.OrganizationAccessPolicyInput-Organization.write.jsonMergePatch" + "$ref": "#/components/schemas/OrganizationOnboarding.ExecuteOrganizationOnboardingStepInput-onboarding.write" } } }, @@ -38090,26 +39915,26 @@ } } }, - "/api/organizations/{organizationId}/domains": { + "/api/onboarding/organization/steps/{stepKey}/skip": { "post": { - "operationId": "organization_join_add_domain", + "operationId": "skipOrganizationOnboardingStep", "tags": [ - "Organization access" + "Onboarding" ], "responses": { "200": { - "description": "OrganizationJoin resource created", + "description": "Onboarding step skipped", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/OrganizationJoin.OrganizationDomainOutput.jsonld-Organization.read" + "$ref": "#/components/schemas/OrganizationOnboarding.OrganizationOnboardingOutput.jsonld-onboarding.read" } } }, "links": {} }, "400": { - "description": "Invalid input", + "description": "Invalid or required step", "content": { "application/ld+json": { "schema": { @@ -38129,6 +39954,9 @@ }, "links": {} }, + "401": { + "description": "Authentication required" + }, "403": { "description": "Forbidden", "content": { @@ -38150,6 +39978,9 @@ }, "links": {} }, + "409": { + "description": "Step not available to skip in current flow state" + }, "422": { "description": "An error occurred", "content": { @@ -38172,13 +40003,13 @@ "links": {} } }, - "summary": "add domain", - "description": "add domain", + "summary": "Skip organization onboarding step", + "description": "Voluntarily skips a non-required onboarding step, allowing the flow to advance to completion without executing it.", "parameters": [ { - "name": "organizationId", + "name": "stepKey", "in": "path", - "description": "OrganizationJoin identifier", + "description": "OrganizationOnboarding identifier", "required": true, "deprecated": false, "schema": { @@ -38187,53 +40018,48 @@ "style": "simple", "explode": false } - ], - "requestBody": { - "description": "The new OrganizationJoin resource", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/OrganizationJoin.OrganizationDomainInput-Organization.write" - } - } - }, - "required": true - } + ] } }, - "/api/organizations/{organizationId}/domains/{domainId}": { - "delete": { - "operationId": "organization_join_remove_domain", + "/api/organizations": { + "get": { + "operationId": "listUserOrganizations", "tags": [ - "Organization access" + "Organization" ], "responses": { "200": { - "description": "OrganizationJoin resource deleted" - }, - "403": { - "description": "Forbidden", + "description": "Organization collection", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" + "type": "object", + "description": "Organization.OrganizationOutput.jsonld-Organization.read collection.", + "allOf": [ + { + "$ref": "#/components/schemas/HydraCollectionBaseSchema" + }, + { + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Organization.OrganizationOutput.jsonld-Organization.read" + } + } + } + } + ] } } - }, - "links": {} + } }, - "404": { - "description": "Not found", + "403": { + "description": "Forbidden", "content": { "application/ld+json": { "schema": { @@ -38254,49 +40080,68 @@ "links": {} } }, - "summary": "remove domain", - "description": "remove domain", + "summary": "List user Organizations", + "description": "Lists Organizations where the authenticated user is a member.", "parameters": [ { - "name": "organizationId", - "in": "path", - "description": "OrganizationJoin identifier", - "required": true, + "name": "page", + "in": "query", + "description": "The collection page number", + "required": false, "deprecated": false, "schema": { - "type": "string" + "type": "integer", + "default": 1 }, - "style": "simple", - "explode": false + "style": "form", + "explode": true }, { - "name": "domainId", - "in": "path", - "description": "OrganizationJoin identifier", - "required": true, + "name": "itemsPerPage", + "in": "query", + "description": "The number of items per page", + "required": false, "deprecated": false, "schema": { - "type": "string" + "type": "integer", + "default": 30, + "minimum": 0, + "maximum": 100 }, - "style": "simple", - "explode": false + "style": "form", + "explode": true + }, + { + "name": "status", + "in": "query", + "description": "Filter by organization status.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "enum": [ + "active", + "suspended", + "archived" + ] + }, + "style": "form", + "explode": true } ] - } - }, - "/api/organizations/{organizationId}/domains/{domainId}/verify": { + }, "post": { - "operationId": "organization_join_verify_domain", + "operationId": "createOrganization", "tags": [ - "Organization access" + "Organization" ], "responses": { - "200": { - "description": "OrganizationJoin resource created", + "201": { + "description": "Organization created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/OrganizationJoin.OrganizationDomainOutput.jsonld-Organization.read" + "$ref": "#/components/schemas/Organization.OrganizationOutput.jsonld-Organization.read" } } }, @@ -38323,6 +40168,9 @@ }, "links": {} }, + "401": { + "description": "Authentication required" + }, "403": { "description": "Forbidden", "content": { @@ -38366,74 +40214,61 @@ "links": {} } }, - "summary": "verify domain", - "description": "verify domain", - "parameters": [ - { - "name": "organizationId", - "in": "path", - "description": "OrganizationJoin identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false + "summary": "Create Organization", + "description": "Creates a Organization and assigns the creator as owner.", + "parameters": [], + "requestBody": { + "description": "The new Organization resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Organization.CreateOrganizationInput-Organization.write" + } + } }, - { - "name": "domainId", - "in": "path", - "description": "OrganizationJoin identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - } - ] + "required": true + } } }, - "/api/organizations/{organizationId}/join": { - "post": { - "operationId": "organization_join_join", + "/api/organizations/legal-types": { + "get": { + "operationId": "listOrganizationLegalTypes", "tags": [ - "Organization access" + "Organization" ], "responses": { "200": { - "description": "OrganizationJoin resource created", + "description": "Organization legal types retrieved", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/OrganizationJoin.OrganizationJoinCompletedOutput.jsonld-Organization.read" + "type": "object", + "description": "OrganizationLegalType.OrganizationLegalTypeOptionOutput.jsonld-Organization.read collection.", + "allOf": [ + { + "$ref": "#/components/schemas/HydraCollectionBaseSchema" + }, + { + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "array", + "items": { + "$ref": "#/components/schemas/OrganizationLegalType.OrganizationLegalTypeOptionOutput.jsonld-Organization.read" + } + } + } + } + ] } } - }, - "links": {} + } }, - "400": { - "description": "Invalid input", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "links": {} + "401": { + "description": "Authentication required" }, "403": { "description": "Forbidden", @@ -38455,60 +40290,40 @@ } }, "links": {} - }, - "422": { - "description": "An error occurred", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" - } - } - }, - "links": {} } }, - "summary": "join", - "description": "join", + "summary": "List organization legal types", + "description": "Returns organization legal entity type values for the Legal profile settings tab select.", "parameters": [ { - "name": "organizationId", - "in": "path", - "description": "OrganizationJoin identifier", - "required": true, + "name": "page", + "in": "query", + "description": "The collection page number", + "required": false, "deprecated": false, "schema": { - "type": "string" + "type": "integer", + "default": 1 }, - "style": "simple", - "explode": false + "style": "form", + "explode": true } ] } }, - "/api/organizations/{organizationId}/join-requests": { + "/api/organizations/{id}": { "get": { - "operationId": "organization_join_organization_requests", + "operationId": "getOrganization", "tags": [ - "Organization access" + "Organization" ], "responses": { "200": { - "description": "OrganizationJoin resource", + "description": "Organization resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/OrganizationJoin.OrganizationJoinRequestsOutput.jsonld-Organization.read" + "$ref": "#/components/schemas/Organization.OrganizationOutput.jsonld-Organization.read" } } } @@ -38556,13 +40371,13 @@ "links": {} } }, - "summary": "organization requests", - "description": "organization requests", + "summary": "Get Organization", + "description": "Returns Organization details if the user has Organization.read permission in that Organization.", "parameters": [ { - "name": "organizationId", + "name": "id", "in": "path", - "description": "OrganizationJoin identifier", + "description": "Organization identifier", "required": true, "deprecated": false, "schema": { @@ -38573,25 +40388,73 @@ } ] }, - "post": { - "operationId": "organization_join_request", + "delete": { + "operationId": "deleteOrganization", "tags": [ - "Organization access" + "Organization" + ], + "responses": { + "204": { + "description": "Organization archived" + }, + "403": { + "description": "Insufficient permissions" + }, + "404": { + "description": "Organization not found" + }, + "422": { + "description": "Missing or mismatched slug confirmation" + } + }, + "summary": "Archive Organization", + "description": "Archives the organization (reversible soft delete — NOT a permanent removal): it is hidden from the default listing and its owned data (facilities, equipment, inspections, interventions) is preserved rather than orphaned, and can be restored through the settings PATCH (isActive: true). Requires the organization.delete permission plus a danger-zone confirmation: the \"slug\" query parameter must exactly match the organization's current slug (case-insensitive, trimmed). A missing or mismatched confirmation is rejected with HTTP 422 and nothing is archived. Idempotent when already archived, provided the confirmation is still correct.", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Organization identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "slug", + "in": "query", + "description": "Danger-zone confirmation: the organization's current slug, typed by the caller.", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "form", + "explode": true + } + ] + }, + "patch": { + "operationId": "updateOrganizationSettings", + "tags": [ + "Organization" ], "responses": { "200": { - "description": "OrganizationJoin resource created", + "description": "Organization updated", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/OrganizationJoin.OrganizationJoinRequestOutput.jsonld-Organization.read" + "$ref": "#/components/schemas/Organization.OrganizationOutput.jsonld-Organization.read" } } }, "links": {} }, "400": { - "description": "Invalid input", + "description": "Invalid request - validation failed", "content": { "application/ld+json": { "schema": { @@ -38612,25 +40475,13 @@ "links": {} }, "403": { - "description": "Forbidden", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "links": {} + "description": "Insufficient permissions" + }, + "404": { + "description": "Organization not found" + }, + "409": { + "description": "Slug already in use" }, "422": { "description": "An error occurred", @@ -38654,13 +40505,13 @@ "links": {} } }, - "summary": "request", - "description": "request", + "summary": "Update Organization settings", + "description": "Updates organization settings — general & branding (name, slug, description, active status) plus the notifications and regional sections. Requires the organization.settings.write permission.", "parameters": [ { - "name": "organizationId", + "name": "id", "in": "path", - "description": "OrganizationJoin identifier", + "description": "Organization identifier", "required": true, "deprecated": false, "schema": { @@ -38669,29 +40520,40 @@ "style": "simple", "explode": false } - ] + ], + "requestBody": { + "description": "The updated Organization resource", + "content": { + "application/merge-patch+json": { + "schema": { + "$ref": "#/components/schemas/Organization.UpdateOrganizationSettingsInput-Organization.write.jsonMergePatch" + } + } + }, + "required": true + } } }, - "/api/organizations/{organizationId}/join-requests/{requestId}/approve": { - "post": { - "operationId": "organization_join_approve_request", + "/api/organizations/{id}/plan": { + "patch": { + "operationId": "changeOrganizationPlan", "tags": [ - "Organization access" + "Organization" ], "responses": { "200": { - "description": "OrganizationJoin resource created", + "description": "Organization plan updated", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/OrganizationJoin.OrganizationJoinRequestOutput.jsonld-Organization.read" + "$ref": "#/components/schemas/Organization.OrganizationOutput.jsonld-Organization.read" } } }, "links": {} }, "400": { - "description": "Invalid input", + "description": "Invalid request - plan not selectable", "content": { "application/ld+json": { "schema": { @@ -38712,25 +40574,13 @@ "links": {} }, "403": { - "description": "Forbidden", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "links": {} + "description": "Insufficient permissions" + }, + "404": { + "description": "Organization or plan not found" + }, + "409": { + "description": "Paid plan requires the billing checkout flow, or current usage exceeds the selected plan limits (confirm with acknowledgeOveruse)" }, "422": { "description": "An error occurred", @@ -38754,25 +40604,13 @@ "links": {} } }, - "summary": "approve request", - "description": "approve request", + "summary": "Change Organization plan", + "description": "Assigns a subscription plan to the organization. Self-service — requires the organization.settings.write permission. When the target plan's caps sit below the current usage, the change is refused with HTTP 409 listing the exceeded resources; re-submit with acknowledgeOveruse: true to confirm the downgrade.", "parameters": [ { - "name": "organizationId", - "in": "path", - "description": "OrganizationJoin identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - }, - { - "name": "requestId", + "name": "id", "in": "path", - "description": "OrganizationJoin identifier", + "description": "Organization identifier", "required": true, "deprecated": false, "schema": { @@ -38783,11 +40621,11 @@ } ], "requestBody": { - "description": "The new OrganizationJoin resource", + "description": "The updated Organization resource", "content": { - "application/ld+json": { + "application/merge-patch+json": { "schema": { - "$ref": "#/components/schemas/OrganizationJoin.ApproveOrganizationJoinRequestInput-Organization.write" + "$ref": "#/components/schemas/Organization.ChangeOrganizationPlanInput-Organization.write.jsonMergePatch" } } }, @@ -38795,19 +40633,19 @@ } } }, - "/api/organizations/{organizationId}/join-requests/{requestId}/reject": { + "/api/organizations/{id}/restore": { "post": { - "operationId": "organization_join_reject_request", + "operationId": "restoreOrganization", "tags": [ - "Organization access" + "Organization" ], "responses": { "200": { - "description": "OrganizationJoin resource created", + "description": "Organization restored — refreshed organization output returned", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/OrganizationJoin.OrganizationJoinRequestOutput.jsonld-Organization.read" + "$ref": "#/components/schemas/Organization.OrganizationOutput.jsonld-Organization.read" } } }, @@ -38835,25 +40673,10 @@ "links": {} }, "403": { - "description": "Forbidden", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "links": {} + "description": "Insufficient permissions — and, for an archived organization, held by every caller who is not a platform administrator" + }, + "404": { + "description": "Organization not found" }, "422": { "description": "An error occurred", @@ -38877,25 +40700,13 @@ "links": {} } }, - "summary": "reject request", - "description": "reject request", + "summary": "Restore Organization", + "description": "Restores the organization to ACTIVE from SUSPENDED or ARCHIVED, as an explicit, dedicated action — coexists with (does not replace) the legacy isActive: true toggle on PATCH /organizations/{id}. Requires the organization.settings.write permission, the SAME permission the legacy toggle already requires — OR platform administrator privileges, which bypass it. The bypass is not a convenience: an ARCHIVED organization refuses organization.settings.write like every other write, so a platform administrator is the only caller who can reopen one. A SUSPENDED organization keeps its self-service path. Idempotent when already active.", "parameters": [ { - "name": "organizationId", - "in": "path", - "description": "OrganizationJoin identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - }, - { - "name": "requestId", + "name": "id", "in": "path", - "description": "OrganizationJoin identifier", + "description": "Organization identifier", "required": true, "deprecated": false, "schema": { @@ -38907,31 +40718,26 @@ ] } }, - "/api/auth/password/reset/confirm": { + "/api/organizations/{id}/suspend": { "post": { - "operationId": "confirm_password_reset", + "operationId": "suspendOrganization", "tags": [ - "Password Reset" + "Organization" ], "responses": { "200": { - "description": "Password reset confirmed successfully. All sessions have been revoked.", + "description": "Organization suspended — refreshed organization output returned", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/PasswordReset.ConfirmPasswordResetOutput.jsonld-token.read" + "$ref": "#/components/schemas/Organization.OrganizationOutput.jsonld-Organization.read" } } }, - "links": { - "Login": { - "operationId": "login", - "description": "Login with the new password" - } - } + "links": {} }, "400": { - "description": "Invalid request - missing or invalid parameters", + "description": "Invalid input", "content": { "application/ld+json": { "schema": { @@ -38951,8 +40757,14 @@ }, "links": {} }, - "401": { - "description": "Invalid or expired token/code" + "403": { + "description": "Insufficient permissions" + }, + "404": { + "description": "Organization not found" + }, + "409": { + "description": "Organization is archived — restore it first" }, "422": { "description": "An error occurred", @@ -38974,52 +40786,46 @@ } }, "links": {} - }, - "429": { - "description": "Too many password reset confirmation attempts or maximum verification attempts exceeded" } }, - "summary": "Confirm Password Reset", - "description": "Confirm password reset using the token, code, and new password. All active sessions will be terminated.", - "parameters": [], - "requestBody": { - "description": "The new PasswordReset resource", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/PasswordReset.ConfirmPasswordResetInput-password_reset.write" - } - } - }, - "required": true - } + "summary": "Suspend Organization", + "description": "Suspends the organization as an explicit, dedicated action — coexists with (does not replace) the legacy isActive: false toggle on PATCH /organizations/{id}. Requires the organization.settings.write permission, the SAME permission the legacy toggle already requires. Idempotent when already suspended.", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Organization identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ] } }, - "/api/auth/password/reset/request": { + "/api/organizations/{id}/transfer-ownership": { "post": { - "operationId": "request_password_reset", + "operationId": "transferOrganizationOwnership", "tags": [ - "Password Reset" + "Organization" ], "responses": { "200": { - "description": "Password reset request processed successfully. If the email exists, a code has been sent.", + "description": "Ownership transferred — refreshed organization output returned", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/PasswordReset.RequestPasswordResetOutput.jsonld-token.read" + "$ref": "#/components/schemas/Organization.OrganizationOutput.jsonld-Organization.read" } } }, - "links": { - "ConfirmReset": { - "operationId": "confirm_password_reset", - "description": "Confirm the password reset using the token and code" - } - } + "links": {} }, "400": { - "description": "Invalid request - missing or invalid email", + "description": "Invalid input", "content": { "application/ld+json": { "schema": { @@ -39039,8 +40845,17 @@ }, "links": {} }, + "403": { + "description": "Caller is not the organization's current owner" + }, + "404": { + "description": "Organization not found, or the target user is not an active member" + }, + "409": { + "description": "Organization is archived, or the target user already owns the organization" + }, "422": { - "description": "An error occurred", + "description": "Missing or mismatched slug confirmation", "content": { "application/ld+json": { "schema": { @@ -39059,20 +40874,30 @@ } }, "links": {} - }, - "429": { - "description": "Too many password reset requests" } }, - "summary": "Request Password Reset", - "description": "Request a password reset by email. An OTP code will be sent to the user's email if the account exists.", - "parameters": [], + "summary": "Transfer Organization ownership", + "description": "Transfers ownership of the organization to another active member. Only the organization's CURRENT owner may call this — independent of RBAC permissions, since no permission grants the right to give away someone else's ownership. Requires a danger-zone confirmation: \"slug\" must exactly match the organization's current slug (case-insensitive, trimmed), mirroring DELETE /organizations/{id}. The new owner automatically receives the organization's system \"admin\" role if they do not already hold it.", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Organization identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], "requestBody": { - "description": "The new PasswordReset resource", + "description": "The new Organization resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/PasswordReset.RequestPasswordResetInput-password_reset.write" + "$ref": "#/components/schemas/Organization.TransferOrganizationOwnershipInput-Organization.write" } } }, @@ -39080,31 +40905,45 @@ } } }, - "/api/auth/password/reset/resend": { - "post": { - "operationId": "resend_password_reset", + "/api/organizations/{organizationId}/audit-events": { + "get": { + "operationId": "listOrganizationAuditEvents", "tags": [ - "Password Reset" + "Organization" ], "responses": { "200": { - "description": "Password reset code resent successfully.", + "description": "OrganizationAuditEvent collection", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/PasswordReset.RequestPasswordResetOutput.jsonld-token.read" + "type": "object", + "description": "OrganizationAuditEvent.OrganizationAuditEventOutput.jsonld-Organization.read collection.", + "allOf": [ + { + "$ref": "#/components/schemas/HydraCollectionBaseSchema" + }, + { + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "array", + "items": { + "$ref": "#/components/schemas/OrganizationAuditEvent.OrganizationAuditEventOutput.jsonld-Organization.read" + } + } + } + } + ] } } - }, - "links": { - "ConfirmReset": { - "operationId": "confirm_password_reset", - "description": "Confirm the password reset using the new token and code" - } } }, - "400": { - "description": "Invalid request - missing or invalid token", + "403": { + "description": "Forbidden", "content": { "application/ld+json": { "schema": { @@ -39123,71 +40962,199 @@ } }, "links": {} - }, - "404": { - "description": "Reset challenge not found or no longer valid" - }, - "422": { - "description": "An error occurred", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" - } - } + } + }, + "summary": "List organization audit events", + "description": "Returns the organization's slice of the audit ledger, newest first, for the activity feed. Requires the `organization.audit.read` permission in the target organization (held by admins through `organization.*`). The payload is deliberately narrower than the platform-level audit API: actor email, IP address, user agent, and ledger chain internals are never exposed here, regardless of platform PII settings, and metadata keys carrying PII or request context are stripped.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "OrganizationAuditEvent identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" }, - "links": {} + "style": "simple", + "explode": false }, - "429": { - "description": "Resend cooldown not yet elapsed" + { + "name": "page", + "in": "query", + "description": "The collection page number", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 1 + }, + "style": "form", + "explode": true + }, + { + "name": "itemsPerPage", + "in": "query", + "description": "The number of items per page", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 30, + "minimum": 0, + "maximum": 100 + }, + "style": "form", + "explode": true + }, + { + "name": "action", + "in": "query", + "description": "Optional exact audit action filter (e.g. organization.member_added).", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "example": "organization.member_added" + }, + "style": "form", + "explode": true + }, + { + "name": "from", + "in": "query", + "description": "Optional inclusive ISO 8601 lower bound on the occurrence datetime.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "date-time", + "example": "2026-03-01T00:00:00Z" + }, + "style": "form", + "explode": true + }, + { + "name": "to", + "in": "query", + "description": "Optional inclusive ISO 8601 upper bound on the occurrence datetime.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "date-time", + "example": "2026-03-29T23:59:59Z" + }, + "style": "form", + "explode": true + } + ] + } + }, + "/api/organizations/{organizationId}/audit-events/export": { + "get": { + "operationId": "exportOrganizationAuditEvents", + "tags": [ + "Organization" + ], + "responses": { + "200": { + "description": "CSV stream" + }, + "204": { + "description": "OrganizationAuditEvent resource" + }, + "400": { + "description": "Malformed date filter" + }, + "403": { + "description": "Member without the organization.audit.export permission" + }, + "404": { + "description": "Organization not found, or the caller is not an active member" + }, + "422": { + "description": "The filters match more rows than the export cap" } }, - "summary": "Resend Password Reset Code", - "description": "Resend the password reset OTP code using the existing challenge token.", - "parameters": [], - "requestBody": { - "description": "The new PasswordReset resource", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/PasswordReset.ResendPasswordResetInput-password_reset.write" - } - } + "summary": "Export organization audit events (CSV)", + "description": "Streams the organization's slice of the audit ledger as CSV, using the same filters as the list endpoint. Requires the `organization.audit.export` permission, which is deliberately separate from `organization.audit.read`: reading keeps the data inside the product, exporting takes a file out. The organization is taken from the URI and is not a filter the caller can widen — unlike the platform-level `/audit-events/export`, which builds its criteria from the request and is therefore reserved to platform operators. The columns match what the read endpoint exposes: no actor email, no IP address, no user agent, no ledger chain internals.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "OrganizationAuditEvent identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false }, - "required": true - } + { + "name": "action", + "in": "query", + "description": "Optional exact audit action filter (e.g. organization.member_added).", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "example": "organization.member_added" + }, + "style": "form", + "explode": true + }, + { + "name": "from", + "in": "query", + "description": "Optional inclusive ISO 8601 lower bound on the occurrence datetime.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "date-time", + "example": "2026-03-01T00:00:00Z" + }, + "style": "form", + "explode": true + }, + { + "name": "to", + "in": "query", + "description": "Optional inclusive ISO 8601 upper bound on the occurrence datetime.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "date-time", + "example": "2026-03-29T23:59:59Z" + }, + "style": "form", + "explode": true + } + ] } }, - "/api/auth/password/setup": { - "post": { - "operationId": "request_initial_password", + "/api/organizations/{organizationId}/dashboard": { + "get": { + "operationId": "getOrganizationDashboard", "tags": [ - "PasswordSetup" + "Organization" ], "responses": { "200": { - "description": "PasswordSetup resource created", + "description": "OrganizationDashboard resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/PasswordSetup.RequestPasswordChangeOutput.jsonld-token.read" + "$ref": "#/components/schemas/OrganizationDashboard.OrganizationDashboardOutput.jsonld-Organization.read" } } - }, - "links": {} + } }, - "400": { - "description": "Invalid input", + "403": { + "description": "Forbidden", "content": { "application/ld+json": { "schema": { @@ -39207,8 +41174,8 @@ }, "links": {} }, - "403": { - "description": "Forbidden", + "404": { + "description": "Not found", "content": { "application/ld+json": { "schema": { @@ -39227,54 +41194,257 @@ } }, "links": {} + } + }, + "summary": "Get organization dashboard", + "description": "Returns lightweight organization-overview KPI data. `overview`, `alerts`, and non-`period*` health KPIs are current snapshots evaluated at `generatedAt`; `comparison` and `period*` health KPIs follow the requested period. `overview` is normalized for frontend consumption and exposes only stable keys and `summary` metric lists. `alerts` expose machine-readable `code`/`severity`/`count`, `health` exposes an iterable `metrics` list, and `comparison.metrics` exposes stable KPI cards for inspections, facilities, members, and equipment with `key`/`label`/`value` plus raw `current`/`previous`/`delta` values. Those cards represent period-over-period changes for inspections performed, facilities created, members joined, and equipment created. Legacy non-conformity opened and resolved comparison entries remain present for backward compatibility. `comparison.health.metrics` exposes percentage-based health deltas. Analytics filters are section-scoped: `facilityType` affects facilities overview and facility comparison cards, `equipmentType` and `equipmentStatus` affect equipment overview and equipment comparison cards, `inspection*` filters affect inspection KPIs and period comparisons, and `nonConformity*` filters affect non-conformity KPIs, alerts, and period comparisons. `overview.nonConformities.severityLow`/`severityMedium`/`severityHigh`/`severityCritical` are the sole exception: an org-wide, unfiltered current-snapshot breakdown by severity across every status, unaffected by `nonConformityStatus`/`nonConformitySeverity`. By default, the endpoint returns the latest 30-day window in UTC and compares it with the previous period. The maximum supported period is 366 days. Datetime bounds must be ISO 8601 strings with an explicit timezone offset, including optional microseconds. Access requires `organization.dashboard.read` plus the underlying read permissions for members, roles, facilities, equipment, and inspections. For chart or sparkline data, use the dedicated `/dashboard/trends/*` endpoints, which require `organization.inspection.read`.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "OrganizationDashboard identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false }, - "422": { - "description": "An error occurred", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" - } - } + { + "name": "from", + "in": "query", + "description": "Inclusive ISO 8601 datetime lower bound for the period-scoped parts of the dashboard, with an explicit timezone offset. Optional microseconds are preserved. If omitted, defaults to 30 days before \"to\" or now. Maximum supported period: 366 days.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "date-time", + "example": "2026-03-01T00:00:00Z" }, - "links": {} + "style": "form", + "explode": true + }, + { + "name": "to", + "in": "query", + "description": "Inclusive ISO 8601 datetime upper bound for the period-scoped parts of the dashboard, with an explicit timezone offset. Optional microseconds are preserved. If omitted, defaults to the current datetime. Maximum supported period: 366 days.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "date-time", + "example": "2026-03-29T23:59:59Z" + }, + "style": "form", + "explode": true + }, + { + "name": "compare", + "in": "query", + "description": "Whether to include previous-period scalar comparison metrics for KPI cards. Defaults to true.", + "required": false, + "deprecated": false, + "schema": { + "type": "boolean", + "default": true, + "example": true + }, + "style": "form", + "explode": true + }, + { + "name": "timezone", + "in": "query", + "description": "IANA timezone used for rendered period values. Defaults to UTC when omitted and not implied by the request bounds. Required when the requested period spans DST, mixes offsets, or uses non-UTC numeric offsets.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "example": "Europe/Paris" + }, + "style": "form", + "explode": true + }, + { + "name": "facilityType", + "in": "query", + "description": "Optional facility type filter applied to the facilities section only.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "enum": [ + "site", + "building", + "floor", + "zone", + "area" + ], + "example": "site" + }, + "style": "form", + "explode": true + }, + { + "name": "equipmentType", + "in": "query", + "description": "Optional equipment type filter applied to the equipment section only.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "enum": [ + "fire_extinguisher", + "smoke_detector", + "heat_detector", + "sprinkler", + "fire_alarm_panel", + "hydrant", + "fire_door", + "emergency_lighting", + "access_control", + "camera", + "gas_detector", + "other" + ], + "example": "fire_extinguisher" + }, + "style": "form", + "explode": true + }, + { + "name": "equipmentStatus", + "in": "query", + "description": "Optional equipment status filter applied to the equipment section only.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "enum": [ + "in_stock", + "operational", + "under_maintenance", + "decommissioned" + ], + "example": "operational" + }, + "style": "form", + "explode": true + }, + { + "name": "inspectionStatus", + "in": "query", + "description": "Optional inspection status filter applied to inspection KPIs, period health, and inspection comparisons.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "enum": [ + "draft", + "submitted", + "closed" + ], + "example": "closed" + }, + "style": "form", + "explode": true + }, + { + "name": "inspectionResult", + "in": "query", + "description": "Optional inspection result filter applied to inspection KPIs, period health, and inspection comparisons.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "enum": [ + "pass", + "fail", + "partial" + ], + "example": "pass" + }, + "style": "form", + "explode": true + }, + { + "name": "inspectorType", + "in": "query", + "description": "Optional inspector type filter applied to inspection KPIs, period health, and inspection comparisons.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "enum": [ + "user", + "external" + ], + "example": "user" + }, + "style": "form", + "explode": true + }, + { + "name": "nonConformityStatus", + "in": "query", + "description": "Optional non-conformity status filter applied to non-conformity KPIs, alerts, period health, and non-conformity comparisons.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "enum": [ + "open", + "in_progress", + "done", + "waived" + ], + "example": "open" + }, + "style": "form", + "explode": true + }, + { + "name": "nonConformitySeverity", + "in": "query", + "description": "Optional non-conformity severity filter applied to non-conformity KPIs, alerts, period health, and non-conformity comparisons.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "enum": [ + "low", + "medium", + "high", + "critical" + ], + "example": "critical" + }, + "style": "form", + "explode": true } - }, - "summary": "Creates a PasswordSetup resource.", - "description": "Creates a PasswordSetup resource.", - "parameters": [] + ] } }, - "/api/auth/password/setup/confirm": { - "post": { - "operationId": "confirm_initial_password", + "/api/organizations/{organizationId}/dashboard/trends/equipment-created": { + "get": { + "operationId": "getOrganizationDashboardEquipmentCreatedTrend", "tags": [ - "PasswordSetup" + "Organization" ], "responses": { "200": { - "description": "PasswordSetup resource created", + "description": "OrganizationDashboardTrend resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/PasswordSetup.ConfirmPasswordChangeOutput.jsonld-token.read" + "$ref": "#/components/schemas/OrganizationDashboardTrend.OrganizationDashboardTrendOutput.jsonld-Organization.read" } } - }, - "links": {} + } }, - "400": { - "description": "Invalid input", + "403": { + "description": "Forbidden", "content": { "application/ld+json": { "schema": { @@ -39294,8 +41464,8 @@ }, "links": {} }, - "403": { - "description": "Forbidden", + "404": { + "description": "Not found", "content": { "application/ld+json": { "schema": { @@ -39314,85 +41484,164 @@ } }, "links": {} - }, - "422": { - "description": "An error occurred", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" - } - } - }, - "links": {} } }, - "summary": "Creates a PasswordSetup resource.", - "description": "Creates a PasswordSetup resource.", - "parameters": [], - "requestBody": { - "description": "The new PasswordSetup resource", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/PasswordSetup.ConfirmPasswordChangeInput-password_change.write" - } - } + "summary": "Get equipment created trend", + "description": "Returns the equipment-created trend as a single chart-ready series. Use this endpoint when a chart needs its own independent period or granularity, separate from the aggregate `/dashboard` payload. Access requires `organization.equipment.read`.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "OrganizationDashboardTrend identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false }, - "required": true - } + { + "name": "from", + "in": "query", + "description": "Inclusive ISO 8601 datetime lower bound for the trend period, with an explicit timezone offset. Optional microseconds are preserved.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "date-time", + "example": "2026-03-01T00:00:00Z" + }, + "style": "form", + "explode": true + }, + { + "name": "to", + "in": "query", + "description": "Inclusive ISO 8601 datetime upper bound for the trend period, with an explicit timezone offset. Optional microseconds are preserved.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "date-time", + "example": "2026-03-29T23:59:59Z" + }, + "style": "form", + "explode": true + }, + { + "name": "compare", + "in": "query", + "description": "Whether to include previous-period comparison series. Defaults to true.", + "required": false, + "deprecated": false, + "schema": { + "type": "boolean", + "default": true, + "example": true + }, + "style": "form", + "explode": true + }, + { + "name": "granularity", + "in": "query", + "description": "Trend aggregation granularity. Allowed values: day, week, month, auto. Defaults to day.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "enum": [ + "day", + "week", + "month", + "auto" + ], + "default": "day", + "example": "week" + }, + "style": "form", + "explode": true + }, + { + "name": "timezone", + "in": "query", + "description": "IANA timezone used for bucket boundaries and rendered period values. Required when the requested period spans DST, mixes offsets, or uses non-UTC numeric offsets.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "example": "Europe/Paris" + }, + "style": "form", + "explode": true + }, + { + "name": "equipmentType", + "in": "query", + "description": "Optional equipment type filter applied to the equipment-created trend only.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "enum": [ + "fire_extinguisher", + "smoke_detector", + "heat_detector", + "sprinkler", + "fire_alarm_panel", + "hydrant", + "fire_door", + "emergency_lighting", + "access_control", + "camera", + "gas_detector", + "other" + ], + "example": "fire_extinguisher" + }, + "style": "form", + "explode": true + }, + { + "name": "equipmentStatus", + "in": "query", + "description": "Optional equipment status filter applied to the equipment-created trend only.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "enum": [ + "in_stock", + "operational", + "under_maintenance", + "decommissioned" + ], + "example": "operational" + }, + "style": "form", + "explode": true + } + ] } }, - "/api/plans": { + "/api/organizations/{organizationId}/dashboard/trends/facilities-created": { "get": { - "operationId": "listPlans", + "operationId": "getOrganizationDashboardFacilitiesCreatedTrend", "tags": [ - "Plan" + "Organization" ], "responses": { "200": { - "description": "Plans retrieved", + "description": "OrganizationDashboardTrend resource", "content": { "application/ld+json": { "schema": { - "type": "object", - "description": "Plan.PlanOutput.jsonld-Plan.read collection.", - "allOf": [ - { - "$ref": "#/components/schemas/HydraCollectionBaseSchemaNoPagination" - }, - { - "type": "object", - "required": [ - "member" - ], - "properties": { - "member": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Plan.PlanOutput.jsonld-Plan.read" - } - } - } - } - ] + "$ref": "#/components/schemas/OrganizationDashboardTrend.OrganizationDashboardTrendOutput.jsonld-Organization.read" } } } }, - "401": { - "description": "Authentication required" - }, "403": { "description": "Forbidden", "content": { @@ -39413,113 +41662,9 @@ } }, "links": {} - } - }, - "summary": "List plans", - "description": "Lists subscription plans. Administrators see every plan; regular users see selectable plans only.", - "parameters": [] - }, - "post": { - "operationId": "createPlan", - "tags": [ - "Plan" - ], - "responses": { - "201": { - "description": "Plan created", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Plan.PlanOutput.jsonld-Plan.read" - } - } - }, - "links": {} - }, - "400": { - "description": "Invalid request - validation failed", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "links": {} - }, - "403": { - "description": "Administrator privileges required" - }, - "409": { - "description": "Plan key already in use" - }, - "422": { - "description": "An error occurred", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" - } - } - }, - "links": {} - } - }, - "summary": "Create plan", - "description": "Creates a subscription plan. Requires platform administrator privileges.", - "parameters": [], - "requestBody": { - "description": "The new Plan resource", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Plan.CreatePlanInput-Plan.write" - } - } - }, - "required": true - } - } - }, - "/api/plans/{id}": { - "get": { - "operationId": "getPlan", - "tags": [ - "Plan" - ], - "responses": { - "200": { - "description": "Plan retrieved", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Plan.PlanOutput.jsonld-Plan.read" - } - } - } }, - "403": { - "description": "Forbidden", + "404": { + "description": "Not found", "content": { "application/ld+json": { "schema": { @@ -39538,18 +41683,15 @@ } }, "links": {} - }, - "404": { - "description": "Plan not found" } }, - "summary": "Get plan", - "description": "Returns a single subscription plan by identifier.", + "summary": "Get facilities created trend", + "description": "Returns the facilities-created trend as a single chart-ready series. Use this endpoint when a chart needs its own independent period or granularity, separate from the aggregate `/dashboard` payload. Access requires `organization.facilities.read`.", "parameters": [ { - "name": "id", + "name": "organizationId", "in": "path", - "description": "Plan identifier", + "description": "OrganizationDashboardTrend identifier", "required": true, "deprecated": false, "schema": { @@ -39557,64 +41699,124 @@ }, "style": "simple", "explode": false - } - ] - }, - "delete": { - "operationId": "deletePlan", - "tags": [ - "Plan" - ], - "responses": { - "204": { - "description": "Plan deleted" }, - "403": { - "description": "Administrator privileges required" + { + "name": "from", + "in": "query", + "description": "Inclusive ISO 8601 datetime lower bound for the trend period, with an explicit timezone offset. Optional microseconds are preserved.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "date-time", + "example": "2026-03-01T00:00:00Z" + }, + "style": "form", + "explode": true }, - "404": { - "description": "Plan not found" + { + "name": "to", + "in": "query", + "description": "Inclusive ISO 8601 datetime upper bound for the trend period, with an explicit timezone offset. Optional microseconds are preserved.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "date-time", + "example": "2026-03-29T23:59:59Z" + }, + "style": "form", + "explode": true }, - "409": { - "description": "The default plan cannot be deleted" - } - }, - "summary": "Delete plan", - "description": "Deletes a subscription plan. The default plan cannot be deleted. Requires platform administrator privileges.", - "parameters": [ { - "name": "id", - "in": "path", - "description": "Plan identifier", - "required": true, + "name": "compare", + "in": "query", + "description": "Whether to include previous-period comparison series. Defaults to true.", + "required": false, "deprecated": false, "schema": { - "type": "string" + "type": "boolean", + "default": true, + "example": true }, - "style": "simple", - "explode": false + "style": "form", + "explode": true + }, + { + "name": "granularity", + "in": "query", + "description": "Trend aggregation granularity. Allowed values: day, week, month, auto. Defaults to day.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "enum": [ + "day", + "week", + "month", + "auto" + ], + "default": "day", + "example": "week" + }, + "style": "form", + "explode": true + }, + { + "name": "timezone", + "in": "query", + "description": "IANA timezone used for bucket boundaries and rendered period values. Required when the requested period spans DST, mixes offsets, or uses non-UTC numeric offsets.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "example": "Europe/Paris" + }, + "style": "form", + "explode": true + }, + { + "name": "facilityType", + "in": "query", + "description": "Optional facility type filter applied to the facilities-created trend only.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "enum": [ + "site", + "building", + "floor", + "zone", + "area" + ], + "example": "site" + }, + "style": "form", + "explode": true } ] - }, - "patch": { - "operationId": "updatePlan", + } + }, + "/api/organizations/{organizationId}/dashboard/trends/inspections": { + "get": { + "operationId": "getOrganizationDashboardInspectionsTrend", "tags": [ - "Plan" + "Organization" ], "responses": { "200": { - "description": "Plan updated", + "description": "OrganizationDashboardTrend resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Plan.PlanOutput.jsonld-Plan.read" + "$ref": "#/components/schemas/OrganizationDashboardTrend.OrganizationDashboardTrendOutput.jsonld-Organization.read" } } - }, - "links": {} + } }, - "400": { - "description": "Invalid request - validation failed", + "403": { + "description": "Forbidden", "content": { "application/ld+json": { "schema": { @@ -39634,41 +41836,35 @@ }, "links": {} }, - "403": { - "description": "Administrator privileges required" - }, "404": { - "description": "Plan not found" - }, - "422": { - "description": "An error occurred", + "description": "Not found", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation.jsonld" + "$ref": "#/components/schemas/Error.jsonld" } }, "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation" + "$ref": "#/components/schemas/Error" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation" + "$ref": "#/components/schemas/Error" } } }, "links": {} } }, - "summary": "Update plan", - "description": "Updates a subscription plan. Requires platform administrator privileges.", + "summary": "Get inspections trend", + "description": "Returns the inspections-performed trend as a single chart-ready series. Use this endpoint when a chart needs its own independent period or granularity, separate from the aggregate `/dashboard` payload. Access requires `organization.inspection.read`.", "parameters": [ { - "name": "id", + "name": "organizationId", "in": "path", - "description": "Plan identifier", + "description": "OrganizationDashboardTrend identifier", "required": true, "deprecated": false, "schema": { @@ -39676,103 +41872,131 @@ }, "style": "simple", "explode": false - } - ], - "requestBody": { - "description": "The updated Plan resource", - "content": { - "application/merge-patch+json": { - "schema": { - "$ref": "#/components/schemas/Plan.UpdatePlanInput-Plan.write.jsonMergePatch" - } - } - }, - "required": true - } - } - }, - "/api/presence": { - "get": { - "operationId": "messaging_get_presence", - "tags": [ - "Presence" - ], - "responses": { - "200": { - "description": "Presence collection", - "content": { - "application/ld+json": { - "schema": { - "type": "object", - "description": "Presence.PresenceOutput.jsonld collection.", - "allOf": [ - { - "$ref": "#/components/schemas/HydraCollectionBaseSchemaNoPagination" - }, - { - "type": "object", - "required": [ - "member" - ], - "properties": { - "member": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Presence.PresenceOutput.jsonld" - } - } - } - } - ] - } - } - } }, - "403": { - "description": "Forbidden", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } + { + "name": "from", + "in": "query", + "description": "Inclusive ISO 8601 datetime lower bound for the trend period, with an explicit timezone offset. Optional microseconds are preserved.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "date-time", + "example": "2026-03-01T00:00:00Z" }, - "links": {} - } - }, - "summary": "Retrieves the collection of Presence resources.", - "description": "Retrieves the collection of Presence resources.", - "parameters": [ + "style": "form", + "explode": true + }, { - "name": "organization", + "name": "to", "in": "query", - "description": "Organization IRI.", - "required": true, + "description": "Inclusive ISO 8601 datetime upper bound for the trend period, with an explicit timezone offset. Optional microseconds are preserved.", + "required": false, "deprecated": false, "schema": { - "type": "string" + "type": "string", + "format": "date-time", + "example": "2026-03-29T23:59:59Z" }, "style": "form", "explode": true }, { - "name": "memberIds", + "name": "compare", "in": "query", - "description": "Comma-separated organization member ids to check presence for (max 100). Required — there is no \"list all online members\" mode.", - "required": true, + "description": "Whether to include previous-period comparison series. Defaults to true.", + "required": false, "deprecated": false, "schema": { - "type": "string" + "type": "boolean", + "default": true, + "example": true + }, + "style": "form", + "explode": true + }, + { + "name": "granularity", + "in": "query", + "description": "Trend aggregation granularity. Allowed values: day, week, month, auto. Defaults to day.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "enum": [ + "day", + "week", + "month", + "auto" + ], + "default": "day", + "example": "week" + }, + "style": "form", + "explode": true + }, + { + "name": "timezone", + "in": "query", + "description": "IANA timezone used for bucket boundaries and rendered period values. Required when the requested period spans DST, mixes offsets, or uses non-UTC numeric offsets.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "example": "Europe/Paris" + }, + "style": "form", + "explode": true + }, + { + "name": "inspectionStatus", + "in": "query", + "description": "Optional inspection status filter applied to the inspection trend only.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "enum": [ + "draft", + "submitted", + "closed" + ], + "example": "closed" + }, + "style": "form", + "explode": true + }, + { + "name": "inspectionResult", + "in": "query", + "description": "Optional inspection result filter applied to the inspection trend only.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "enum": [ + "pass", + "fail", + "partial" + ], + "example": "pass" + }, + "style": "form", + "explode": true + }, + { + "name": "inspectorType", + "in": "query", + "description": "Optional inspector type filter applied to the inspection trend only.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "enum": [ + "user", + "external" + ], + "example": "user" }, "style": "form", "explode": true @@ -39780,26 +42004,25 @@ ] } }, - "/api/presence/ping": { - "post": { - "operationId": "messaging_ping_presence", + "/api/organizations/{organizationId}/dashboard/trends/non-conformities-opened": { + "get": { + "operationId": "getOrganizationDashboardNonConformitiesOpenedTrend", "tags": [ - "Presence" + "Organization" ], "responses": { "200": { - "description": "Presence resource created", + "description": "OrganizationDashboardTrend resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Presence.PingPresenceOutput.jsonld" + "$ref": "#/components/schemas/OrganizationDashboardTrend.OrganizationDashboardTrendOutput.jsonld-Organization.read" } } - }, - "links": {} + } }, - "400": { - "description": "Invalid input", + "403": { + "description": "Forbidden", "content": { "application/ld+json": { "schema": { @@ -39819,8 +42042,8 @@ }, "links": {} }, - "403": { - "description": "Forbidden", + "404": { + "description": "Not found", "content": { "application/ld+json": { "schema": { @@ -39839,58 +42062,165 @@ } }, "links": {} - }, - "422": { - "description": "An error occurred", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" - } - } - }, - "links": {} } }, - "summary": "Creates a Presence resource.", - "description": "Creates a Presence resource.", - "parameters": [], - "requestBody": { - "description": "The new Presence resource", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Presence.PingPresenceInput" - } - } + "summary": "Get non-conformities opened trend", + "description": "Returns the non-conformities-opened trend as a single chart-ready series. Use this endpoint when a chart needs its own independent period or granularity, separate from the aggregate `/dashboard` payload. Access requires `organization.inspection.read`.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "OrganizationDashboardTrend identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false }, - "required": true - } + { + "name": "from", + "in": "query", + "description": "Inclusive ISO 8601 datetime lower bound for the trend period, with an explicit timezone offset. Optional microseconds are preserved.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "date-time", + "example": "2026-03-01T00:00:00Z" + }, + "style": "form", + "explode": true + }, + { + "name": "to", + "in": "query", + "description": "Inclusive ISO 8601 datetime upper bound for the trend period, with an explicit timezone offset. Optional microseconds are preserved.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "date-time", + "example": "2026-03-29T23:59:59Z" + }, + "style": "form", + "explode": true + }, + { + "name": "compare", + "in": "query", + "description": "Whether to include previous-period comparison series. Defaults to true.", + "required": false, + "deprecated": false, + "schema": { + "type": "boolean", + "default": true, + "example": true + }, + "style": "form", + "explode": true + }, + { + "name": "granularity", + "in": "query", + "description": "Trend aggregation granularity. Allowed values: day, week, month, auto. Defaults to day.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "enum": [ + "day", + "week", + "month", + "auto" + ], + "default": "day", + "example": "week" + }, + "style": "form", + "explode": true + }, + { + "name": "timezone", + "in": "query", + "description": "IANA timezone used for bucket boundaries and rendered period values. Required when the requested period spans DST, mixes offsets, or uses non-UTC numeric offsets.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "example": "Europe/Paris" + }, + "style": "form", + "explode": true + }, + { + "name": "nonConformityStatus", + "in": "query", + "description": "Optional non-conformity status filter applied to the non-conformity trend only.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "enum": [ + "open", + "in_progress", + "done", + "waived" + ], + "example": "open" + }, + "style": "form", + "explode": true + }, + { + "name": "nonConformitySeverity", + "in": "query", + "description": "Optional non-conformity severity filter applied to the non-conformity trend only.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "enum": [ + "low", + "medium", + "high", + "critical" + ], + "example": "critical" + }, + "style": "form", + "explode": true + }, + { + "name": "metrics", + "in": "query", + "description": "Optional comma-separated list of additional non-conformity metric identifiers to combine into `seriesByMetric`, sharing this call's resolved period, timezone and granularity so a two-series (opened vs resolved) chart can render from one request instead of two independently-bucketed calls. Allowed values: non_conformities_opened, non_conformities_resolved.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "example": "non_conformities_opened,non_conformities_resolved" + }, + "style": "form", + "explode": true + } + ] } }, - "/api/presence/subscription": { + "/api/organizations/{organizationId}/dashboard/trends/non-conformities-resolved": { "get": { - "operationId": "messaging_presence_subscription", + "operationId": "getOrganizationDashboardNonConformitiesResolvedTrend", "tags": [ - "Presence" + "Organization" ], "responses": { "200": { - "description": "Presence resource", + "description": "OrganizationDashboardTrend resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Presence.PresenceSubscriptionOutput.jsonld-presence.read" + "$ref": "#/components/schemas/OrganizationDashboardTrend.OrganizationDashboardTrendOutput.jsonld-Organization.read" } } } @@ -39938,43 +42268,170 @@ "links": {} } }, - "summary": "Retrieves a Presence resource.", - "description": "Retrieves a Presence resource.", + "summary": "Get non-conformities resolved trend", + "description": "Returns the non-conformities-resolved trend as a single chart-ready series. Use this endpoint when a chart needs its own independent period or granularity, separate from the aggregate `/dashboard` payload. Access requires `organization.inspection.read`.", "parameters": [ { - "name": "organization", - "in": "query", - "description": "Presence organization", + "name": "organizationId", + "in": "path", + "description": "OrganizationDashboardTrend identifier", "required": true, "deprecated": false, "schema": { "type": "string" }, + "style": "simple", + "explode": false + }, + { + "name": "from", + "in": "query", + "description": "Inclusive ISO 8601 datetime lower bound for the trend period, with an explicit timezone offset. Optional microseconds are preserved.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "date-time", + "example": "2026-03-01T00:00:00Z" + }, + "style": "form", + "explode": true + }, + { + "name": "to", + "in": "query", + "description": "Inclusive ISO 8601 datetime upper bound for the trend period, with an explicit timezone offset. Optional microseconds are preserved.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "date-time", + "example": "2026-03-29T23:59:59Z" + }, + "style": "form", + "explode": true + }, + { + "name": "compare", + "in": "query", + "description": "Whether to include previous-period comparison series. Defaults to true.", + "required": false, + "deprecated": false, + "schema": { + "type": "boolean", + "default": true, + "example": true + }, + "style": "form", + "explode": true + }, + { + "name": "granularity", + "in": "query", + "description": "Trend aggregation granularity. Allowed values: day, week, month, auto. Defaults to day.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "enum": [ + "day", + "week", + "month", + "auto" + ], + "default": "day", + "example": "week" + }, + "style": "form", + "explode": true + }, + { + "name": "timezone", + "in": "query", + "description": "IANA timezone used for bucket boundaries and rendered period values. Required when the requested period spans DST, mixes offsets, or uses non-UTC numeric offsets.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "example": "Europe/Paris" + }, + "style": "form", + "explode": true + }, + { + "name": "nonConformityStatus", + "in": "query", + "description": "Optional non-conformity status filter applied to the non-conformity trend only.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "enum": [ + "open", + "in_progress", + "done", + "waived" + ], + "example": "open" + }, + "style": "form", + "explode": true + }, + { + "name": "nonConformitySeverity", + "in": "query", + "description": "Optional non-conformity severity filter applied to the non-conformity trend only.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "enum": [ + "low", + "medium", + "high", + "critical" + ], + "example": "critical" + }, + "style": "form", + "explode": true + }, + { + "name": "metrics", + "in": "query", + "description": "Optional comma-separated list of additional non-conformity metric identifiers to combine into `seriesByMetric`, sharing this call's resolved period, timezone and granularity so a two-series (opened vs resolved) chart can render from one request instead of two independently-bucketed calls. Allowed values: non_conformities_opened, non_conformities_resolved.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "example": "non_conformities_opened,non_conformities_resolved" + }, "style": "form", "explode": true } ] } }, - "/api/me/presence-preference": { - "get": { - "operationId": "user_get_presence_preference", + "/api/organizations/{organizationId}/logo": { + "post": { + "operationId": "uploadOrganizationLogo", "tags": [ - "PresencePreference" + "Organization" ], "responses": { "200": { - "description": "PresencePreference resource", + "description": "Logo uploaded — organization output with updated logoUrl returned", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/PresencePreference.PresencePreferenceOutput.jsonld-presence-preference.read" + "$ref": "#/components/schemas/Organization.OrganizationOutput.jsonld-Organization.read" } } - } + }, + "links": {} }, - "403": { - "description": "Forbidden", + "400": { + "description": "Invalid input", "content": { "application/ld+json": { "schema": { @@ -39994,51 +42451,166 @@ }, "links": {} }, + "403": { + "description": "Insufficient permissions" + }, "404": { - "description": "Not found", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" + "description": "Organization not found" + }, + "422": { + "description": "Invalid file — missing, too large, or unsupported MIME type", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" } }, "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/ConstraintViolation" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/ConstraintViolation" } } }, "links": {} } }, - "summary": "Retrieves a PresencePreference resource.", - "description": "Retrieves a PresencePreference resource.", - "parameters": [] + "summary": "Upload Organization logo", + "description": "Uploads a logo image for the organization. Accepted formats: JPEG, PNG, WebP, GIF (max 5 MB). The image is scaled down to a single WebP variant. Requires the organization.settings.write permission.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "Organization identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], + "requestBody": { + "description": "Logo image file (JPEG, PNG, WebP or GIF, max 5 MB)", + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "required": [ + "logo" + ], + "properties": { + "logo": { + "type": "string", + "format": "binary", + "description": "Image file (JPEG, PNG, WebP or GIF), max 5 MB" + } + } + } + } + }, + "required": true + } }, - "patch": { - "operationId": "user_update_presence_preference", + "delete": { + "operationId": "removeOrganizationLogo", "tags": [ - "PresencePreference" + "Organization" + ], + "responses": { + "204": { + "description": "Logo removed" + }, + "403": { + "description": "Insufficient permissions" + }, + "404": { + "description": "Organization not found" + }, + "409": { + "description": "Organization is archived — restore it first" + } + }, + "summary": "Remove Organization logo", + "description": "Removes the organization logo. Requires the organization.settings.write permission (the same permission required to upload it). Idempotent when the organization has no logo.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "Organization identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ] + } + }, + "/api/organizations/{organizationId}/logo.webp": { + "get": { + "operationId": "getOrganizationLogo", + "tags": [ + "Organization" ], "responses": { "200": { - "description": "PresencePreference resource updated", + "description": "Logo image (WebP)" + }, + "204": { + "description": "Organization resource" + }, + "404": { + "description": "Organization logo not found" + } + }, + "summary": "Get Organization logo", + "description": "Streams the organization logo as WebP. Public endpoint.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "Organization identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ] + } + }, + "/api/organizations/{organizationId}/navigation-counters": { + "get": { + "operationId": "getOrganizationNavigationCounters", + "tags": [ + "Organization" + ], + "responses": { + "200": { + "description": "Navigation counters retrieved successfully", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/PresencePreference.PresencePreferenceOutput.jsonld-presence-preference.read" + "$ref": "#/components/schemas/OrganizationNavigationCounters.OrganizationNavigationCountersOutput.jsonld-Organization.read" } } - }, - "links": {} + } }, - "400": { - "description": "Invalid input", + "401": { + "description": "Authentication required" + }, + "403": { + "description": "Forbidden", "content": { "application/ld+json": { "schema": { @@ -40058,26 +42630,52 @@ }, "links": {} }, - "403": { - "description": "Forbidden", + "404": { + "description": "Organization not found or the authenticated user is not an active member" + } + }, + "summary": "Get organization sidebar navigation counters", + "description": "Returns lightweight badge counters for the frontend sidebar navigation: `openInterventions` (field interventions not yet `published`/`abandoned`) and `openNonConformities` (non-conformities currently `open` or `in_progress`). The caller only needs to be an ACTIVE member of the organization; each counter individually falls back to 0 (never a 403) when the caller lacks the underlying read permission (`organization.interventions.read` / `organization.inspection.read` respectively), mirroring how the organization dashboard degrades its own interventions section.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "OrganizationNavigationCounters identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/api/organizations/{organizationId}/quota": { + "get": { + "operationId": "getOrganizationQuota", + "tags": [ + "Organization" + ], + "responses": { + "200": { + "description": "Quota usage retrieved", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Organization.OrganizationQuotaOutput.jsonld-Organization.read" } } - }, - "links": {} + } + }, + "403": { + "description": "Insufficient permissions" }, "404": { "description": "Not found", @@ -40099,62 +42697,49 @@ } }, "links": {} - }, - "422": { - "description": "An error occurred", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" - } - } - }, - "links": {} } }, - "summary": "Updates the PresencePreference resource.", - "description": "Updates the PresencePreference resource.", - "parameters": [], - "requestBody": { - "description": "The updated PresencePreference resource", - "content": { - "application/merge-patch+json": { - "schema": { - "$ref": "#/components/schemas/PresencePreference.UpdatePresencePreferenceInput-presence-preference.write.jsonMergePatch" - } - } - }, - "required": true - } + "summary": "Get Organization quota usage", + "description": "Returns the current usage and plan limit of each capped resource (members, facilities, equipment, inspections). Requires the organization.read permission.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "Organization identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ] } }, - "/api/me/presence-preference/subscription": { + "/api/organizations/{organizationId}/search": { "get": { - "operationId": "user_presence_preference_subscription", + "operationId": "searchOrganization", "tags": [ - "PresencePreference" + "Organization" ], "responses": { "200": { - "description": "PresencePreference resource", + "description": "Search results retrieved", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/PresencePreference.PresencePreferenceSubscriptionOutput.jsonld-presence-preference.read" + "$ref": "#/components/schemas/OrganizationSearch.OrganizationSearchOutput.jsonld-Organization.read" } } } }, + "400": { + "description": "Missing q parameter, or q shorter than 2 / longer than 100 characters" + }, + "401": { + "description": "Authentication required" + }, "403": { "description": "Forbidden", "content": { @@ -40177,45 +42762,54 @@ "links": {} }, "404": { - "description": "Not found", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "links": {} + "description": "Organization not found or the caller is not an active member — deliberately not 403, which would confirm it exists" } }, - "summary": "Retrieves a PresencePreference resource.", - "description": "Retrieves a PresencePreference resource.", - "parameters": [] + "summary": "Search across an organization", + "description": "Case-insensitive free-text search returning a flat `results` list, grouped by type in a stable order (`equipment`, `facility`, `intervention`, `inspection`, `non_conformity`), with at most 5 hits per type ordered by most recent update. Each hit carries `type`, `id`, `title` and optional `subtitle`/`extra`; the frontend builds the target route from `type` + `id` — the API ships no URL. Matched fields per type: equipment type/brand/model/serial number/location label; facility name/code/address; intervention name/number; inspection checklist reference code or inspection id; non-conformity description. The caller must be an ACTIVE member of the organization (a non-member gets 404, never a hint the organization exists); each type is then individually soft-gated on its read permission (`organization.equipment.read`, `organization.facilities.read`, `organization.interventions.read`, `organization.inspection.read` for both inspections and non-conformities) — a member without a permission simply gets no rows of that type, never an error.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "OrganizationSearch identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "q", + "in": "query", + "description": "The free-text search term, 2 to 100 characters after trimming.", + "required": true, + "deprecated": false, + "schema": { + "type": "string", + "minLength": 2, + "maxLength": 100 + }, + "style": "form", + "explode": true + } + ] } }, - "/api/publications": { + "/api/organizations/invitations/accept": { "post": { - "operationId": "publication_create", + "operationId": "acceptOrganizationInvitation", "tags": [ - "Publication" + "Organization Invitations" ], "responses": { - "202": { - "description": "Publication resource created", + "201": { + "description": "OrganizationInvitation resource created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Publication.PublicationOutput.jsonld" + "$ref": "#/components/schemas/OrganizationInvitation.OrganizationMemberOutput.jsonld-Organization.read" } } }, @@ -40285,15 +42879,15 @@ "links": {} } }, - "summary": "Creates a Publication resource.", - "description": "Creates a Publication resource.", + "summary": "Accept Organization invitation", + "description": "Accepts an invitation token and creates/updates organization membership for the authenticated user.", "parameters": [], "requestBody": { - "description": "The new Publication resource", + "description": "The new OrganizationInvitation resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Publication.CreatePublicationInput" + "$ref": "#/components/schemas/OrganizationInvitation.AcceptOrganizationInvitationInput-Organization.write" } } }, @@ -40301,19 +42895,19 @@ } } }, - "/api/publications/{id}": { + "/api/organizations/invitations/{token}/preview": { "get": { - "operationId": "publication_get", + "operationId": "getOrganizationInvitationPreview", "tags": [ - "Publication" + "Organization Invitations" ], "responses": { "200": { - "description": "Publication resource", + "description": "OrganizationInvitation resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Publication.PublicationOutput.jsonld" + "$ref": "#/components/schemas/OrganizationInvitation.OrganizationInvitationPreviewOutput.jsonld-Organization.read" } } } @@ -40361,13 +42955,13 @@ "links": {} } }, - "summary": "Retrieves a Publication resource.", - "description": "Retrieves a Publication resource.", + "summary": "Preview Organization invitation", + "description": "Returns a minimal, public-safe preview of an invitation resolved by token (organization, inviter, invited email, status, expiry, granted role names).", "parameters": [ { - "name": "id", + "name": "token", "in": "path", - "description": "Publication identifier", + "description": "OrganizationInvitation identifier", "required": true, "deprecated": false, "schema": { @@ -40379,31 +42973,45 @@ ] } }, - "/api/auth/register": { - "post": { - "operationId": "register_user", + "/api/organizations/{organizationId}/invitations": { + "get": { + "operationId": "listOrganizationInvitations", "tags": [ - "Registration" + "Organization Invitations" ], "responses": { - "201": { - "description": "Account created. A verification code has been sent to the email.", + "200": { + "description": "OrganizationInvitation collection", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Registration.RegisterOutput.jsonld-token.read" + "type": "object", + "description": "OrganizationInvitation.OrganizationInvitationOutput.jsonld-Organization.read collection.", + "allOf": [ + { + "$ref": "#/components/schemas/HydraCollectionBaseSchema" + }, + { + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "array", + "items": { + "$ref": "#/components/schemas/OrganizationInvitation.OrganizationInvitationOutput.jsonld-Organization.read" + } + } + } + } + ] } } - }, - "links": { - "ConfirmRegistration": { - "operationId": "confirm_registration", - "description": "Verify the email using the token and code" - } } }, - "400": { - "description": "Invalid request - validation failed", + "403": { + "description": "Forbidden", "content": { "application/ld+json": { "schema": { @@ -40422,71 +43030,72 @@ } }, "links": {} + } + }, + "summary": "List Organization invitations", + "description": "Lists invitations created for an Organization.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "OrganizationInvitation identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false }, - "409": { - "description": "An account already exists with this email" - }, - "422": { - "description": "An error occurred", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" - } - } + { + "name": "page", + "in": "query", + "description": "The collection page number", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 1 }, - "links": {} + "style": "form", + "explode": true }, - "429": { - "description": "Too many registration requests" + { + "name": "itemsPerPage", + "in": "query", + "description": "The number of items per page", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 30, + "minimum": 0, + "maximum": 100 + }, + "style": "form", + "explode": true } - }, - "summary": "Register a new account", - "description": "Creates a pending-verification account and emails an OTP verification code.", - "parameters": [], - "requestBody": { - "description": "The new Registration resource", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Registration.RegisterInput-registration.write" - } - } - }, - "required": true - } - } - }, - "/api/auth/register/resend": { + ] + }, "post": { - "operationId": "resend_registration", + "operationId": "inviteOrganizationMember", "tags": [ - "Registration" + "Organization Invitations" ], "responses": { - "200": { - "description": "A new verification code has been sent.", + "201": { + "description": "OrganizationInvitation resource created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Registration.RegisterOutput.jsonld-token.read" + "$ref": "#/components/schemas/OrganizationInvitation.OrganizationInvitationOutput.jsonld-Organization.read" } } }, "links": {} }, "400": { - "description": "Invalid request - missing or invalid token", + "description": "Invalid input", "content": { "application/ld+json": { "schema": { @@ -40506,84 +43115,17 @@ }, "links": {} }, - "404": { - "description": "Verification challenge not found or no longer valid" - }, - "422": { - "description": "An error occurred", + "403": { + "description": "Forbidden", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation.jsonld" + "$ref": "#/components/schemas/Error.jsonld" } }, "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" - } - } - }, - "links": {} - }, - "429": { - "description": "Resend cooldown not yet elapsed" - } - }, - "summary": "Resend the verification code", - "description": "Resends the email-verification OTP code using the existing challenge token.", - "parameters": [], - "requestBody": { - "description": "The new Registration resource", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Registration.ResendRegistrationInput-registration.write" - } - } - }, - "required": true - } - } - }, - "/api/auth/register/verify": { - "post": { - "operationId": "confirm_registration", - "tags": [ - "Registration" - ], - "responses": { - "200": { - "description": "Email verified. Access token returned and refresh token cookie set.", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Registration.LoginOutput.jsonld-token.read" - } - } - }, - "links": { - "RefreshToken": { - "operationId": "refresh", - "description": "Use the refresh token cookie to get a new access token" - } - } - }, - "400": { - "description": "Invalid request - missing or invalid parameters", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/Error" } }, "application/json": { @@ -40594,9 +43136,6 @@ }, "links": {} }, - "401": { - "description": "Invalid or expired token/code" - }, "422": { "description": "An error occurred", "content": { @@ -40617,20 +43156,30 @@ } }, "links": {} - }, - "429": { - "description": "Maximum verification attempts exceeded" } }, - "summary": "Verify email and log in", - "description": "Verifies the OTP code, activates the account, and returns an access token (and refresh token cookie) so the user is logged in automatically.", - "parameters": [], + "summary": "Invite Organization member", + "description": "Invites a user by email to join an Organization with one or more roles.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "OrganizationInvitation identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], "requestBody": { - "description": "The new Registration resource", + "description": "The new OrganizationInvitation resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Registration.ConfirmRegistrationInput-registration.write" + "$ref": "#/components/schemas/OrganizationInvitation.InviteOrganizationMemberInput-Organization.write" } } }, @@ -40638,39 +43187,26 @@ } } }, - "/api/organizations/{organizationId}/compliance/export": { - "get": { - "operationId": "exportOrganizationSafetyRegister", + "/api/organizations/{organizationId}/invitations/{invitationId}/resend": { + "post": { + "operationId": "resendOrganizationInvitation", "tags": [ - "SafetyRegisterExport" + "Organization Invitations" ], "responses": { - "204": { - "description": "SafetyRegisterExport resource" - }, - "403": { - "description": "Forbidden", + "200": { + "description": "OrganizationInvitation resource created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/OrganizationInvitation.OrganizationInvitationOutput.jsonld-Organization.read" } } }, "links": {} }, - "404": { - "description": "Not found", + "400": { + "description": "Invalid input", "content": { "application/ld+json": { "schema": { @@ -40689,35 +43225,6 @@ } }, "links": {} - } - }, - "summary": "Retrieves a SafetyRegisterExport resource.", - "description": "Retrieves a SafetyRegisterExport resource.", - "parameters": [ - { - "name": "organizationId", - "in": "path", - "description": "SafetyRegisterExport identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - } - ] - } - }, - "/api/organizations/{organizationId}/facilities/{facilityId}/compliance/export": { - "get": { - "operationId": "exportFacilitySafetyRegister", - "tags": [ - "SafetyRegisterExport" - ], - "responses": { - "204": { - "description": "SafetyRegisterExport resource" }, "403": { "description": "Forbidden", @@ -40740,35 +43247,35 @@ }, "links": {} }, - "404": { - "description": "Not found", + "422": { + "description": "An error occurred", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Error.jsonld" + "$ref": "#/components/schemas/ConstraintViolation.jsonld" } }, "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/ConstraintViolation" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/ConstraintViolation" } } }, "links": {} } }, - "summary": "Retrieves a SafetyRegisterExport resource.", - "description": "Retrieves a SafetyRegisterExport resource.", + "summary": "Resend Organization invitation", + "description": "Regenerates the token, resets the expiry and re-sends the invitation email, returning a fresh accept link.", "parameters": [ { "name": "organizationId", "in": "path", - "description": "SafetyRegisterExport identifier", + "description": "OrganizationInvitation identifier", "required": true, "deprecated": false, "schema": { @@ -40778,9 +43285,9 @@ "explode": false }, { - "name": "facilityId", + "name": "invitationId", "in": "path", - "description": "SafetyRegisterExport identifier", + "description": "OrganizationInvitation identifier", "required": true, "deprecated": false, "schema": { @@ -40792,98 +43299,22 @@ ] } }, - "/api/sessions": { - "get": { - "operationId": "session_list", + "/api/organizations/{organizationId}/invitations/{invitationId}/revoke": { + "post": { + "operationId": "revokeOrganizationInvitation", "tags": [ - "Sessions" + "Organization Invitations" ], "responses": { "200": { - "description": "List of sessions retrieved successfully", + "description": "OrganizationInvitation resource created", "content": { "application/ld+json": { "schema": { - "type": "object", - "description": "Session.SessionOutput.jsonld-session.read collection.", - "allOf": [ - { - "$ref": "#/components/schemas/HydraCollectionBaseSchema" - }, - { - "type": "object", - "required": [ - "member" - ], - "properties": { - "member": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Session.SessionOutput.jsonld-session.read" - } - } - } - } - ] + "$ref": "#/components/schemas/OrganizationInvitation.OrganizationInvitationOutput.jsonld-Organization.read" } } - } - }, - "401": { - "description": "Authentication required" - }, - "403": { - "description": "Insufficient permissions" - } - }, - "summary": "List sessions", - "description": "Returns all active sessions for the authenticated user.", - "parameters": [ - { - "name": "page", - "in": "query", - "description": "The collection page number", - "required": false, - "deprecated": false, - "schema": { - "type": "integer", - "default": 1 - }, - "style": "form", - "explode": true - }, - { - "name": "itemsPerPage", - "in": "query", - "description": "The number of items per page", - "required": false, - "deprecated": false, - "schema": { - "type": "integer", - "default": 30, - "minimum": 0, - "maximum": 100 }, - "style": "form", - "explode": true - } - ], - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/api/sessions/revoke-all": { - "post": { - "operationId": "session_revoke_all", - "tags": [ - "Sessions" - ], - "responses": { - "204": { - "description": "All sessions revoked successfully", "links": {} }, "400": { @@ -40907,64 +43338,8 @@ }, "links": {} }, - "401": { - "description": "Authentication required" - }, "403": { - "description": "Insufficient permissions" - }, - "422": { - "description": "An error occurred", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" - } - } - }, - "links": {} - } - }, - "summary": "Revoke all sessions", - "description": "Revoke all sessions for the authenticated user (logout from all devices).", - "parameters": [], - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/api/sessions/revoke-others": { - "post": { - "operationId": "session_revoke_others", - "tags": [ - "Sessions" - ], - "responses": { - "200": { - "description": "Other sessions revoked successfully", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Session.RevokeOtherSessionsOutput.jsonld-session.revoke_others" - } - } - }, - "links": {} - }, - "400": { - "description": "Invalid input", + "description": "Forbidden", "content": { "application/ld+json": { "schema": { @@ -40984,12 +43359,6 @@ }, "links": {} }, - "401": { - "description": "Authentication required" - }, - "403": { - "description": "Insufficient permissions" - }, "422": { "description": "An error occurred", "content": { @@ -41012,29 +43381,49 @@ "links": {} } }, - "summary": "Revoke other sessions", - "description": "Revokes every active session of the authenticated user except the one backing the current request, signing the user out on every other device while staying signed in here. Idempotent: calling it again once there is nothing else to revoke returns a zero count.", - "parameters": [], - "security": [ + "summary": "Revoke Organization invitation", + "description": "Revokes a pending invitation.", + "parameters": [ { - "bearerAuth": [] + "name": "organizationId", + "in": "path", + "description": "OrganizationInvitation identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "invitationId", + "in": "path", + "description": "OrganizationInvitation identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false } ] } }, - "/api/sessions/{id}": { + "/api/organizations/{organizationId}/me": { "get": { - "operationId": "session_get", + "operationId": "getCurrentOrganizationMemberProfile", "tags": [ - "Sessions" + "Organization Members" ], "responses": { "200": { - "description": "Session retrieved successfully", + "description": "Current organization member profile retrieved successfully", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Session.SessionOutput.jsonld-session.read" + "$ref": "#/components/schemas/CurrentOrganizationMemberProfile.CurrentOrganizationMemberProfileOutput.jsonld-Organization.read" } } } @@ -41043,60 +43432,37 @@ "description": "Authentication required" }, "403": { - "description": "Insufficient permissions" - }, - "404": { - "description": "Session not found" - } - }, - "summary": "Get session", - "description": "Get details of a specific session.", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "Session identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } }, - "style": "simple", - "explode": false - } - ], - "security": [ - { - "bearerAuth": [] - } - ] - }, - "delete": { - "operationId": "session_revoke", - "tags": [ - "Sessions" - ], - "responses": { - "204": { - "description": "Session revoked successfully" - }, - "401": { - "description": "Authentication required" - }, - "403": { - "description": "Insufficient permissions" + "links": {} }, "404": { - "description": "Session not found" + "description": "Organization not found or the authenticated user is not an active member" } }, - "summary": "Revoke session", - "description": "Revoke a specific session. The user will be logged out from that device.", + "summary": "Get current organization member profile", + "description": "Returns the authenticated active member profile in the target organization, including resolved roles and effective permissions.", "parameters": [ { - "name": "id", + "name": "organizationId", "in": "path", - "description": "Session identifier", + "description": "CurrentOrganizationMemberProfile identifier", "required": true, "deprecated": false, "schema": { @@ -41113,20 +43479,20 @@ ] } }, - "/api/organizations/{organizationId}/teams": { + "/api/organizations/{organizationId}/members": { "get": { - "operationId": "listTeams", + "operationId": "listOrganizationMembers", "tags": [ - "Teams" + "Organization Members" ], "responses": { "200": { - "description": "Team collection", + "description": "OrganizationMember collection", "content": { "application/ld+json": { "schema": { "type": "object", - "description": "Team.TeamOutput.jsonld-Organization.read collection.", + "description": "OrganizationMember.OrganizationMemberOutput.jsonld-Organization.read collection.", "allOf": [ { "$ref": "#/components/schemas/HydraCollectionBaseSchema" @@ -41140,7 +43506,7 @@ "member": { "type": "array", "items": { - "$ref": "#/components/schemas/Team.TeamOutput.jsonld-Organization.read" + "$ref": "#/components/schemas/OrganizationMember.OrganizationMemberOutput.jsonld-Organization.read" } } } @@ -41172,13 +43538,13 @@ "links": {} } }, - "summary": "List teams", - "description": "Lists all teams defined for an organization. Requires organization.teams.read.", + "summary": "List Organization members", + "description": "Lists Organization members and assigned roles. Supports `search` (matched against the member's user identifier), `status` (`active`, `inactive`, or `all`), `roleId`, and `order[joinedAt|displayName]=asc|desc` (default `order[joinedAt]=asc`).", "parameters": [ { "name": "organizationId", "in": "path", - "description": "Team identifier", + "description": "OrganizationMember identifier", "required": true, "deprecated": false, "schema": { @@ -41199,21 +43565,36 @@ }, "style": "form", "explode": true + }, + { + "name": "itemsPerPage", + "in": "query", + "description": "The number of items per page", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 30, + "minimum": 0, + "maximum": 100 + }, + "style": "form", + "explode": true } ] }, "post": { - "operationId": "createTeam", + "operationId": "addOrganizationMember", "tags": [ - "Teams" + "Organization Members" ], "responses": { "201": { - "description": "Team created", + "description": "OrganizationMember resource created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Team.TeamOutput.jsonld-Organization.read" + "$ref": "#/components/schemas/OrganizationMember.OrganizationMemberOutput.jsonld-Organization.read" } } }, @@ -41241,13 +43622,25 @@ "links": {} }, "403": { - "description": "Insufficient permissions" - }, - "404": { - "description": "Organization not found" - }, - "409": { - "description": "A team with this name already exists in the organization" + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} }, "422": { "description": "An error occurred", @@ -41271,13 +43664,13 @@ "links": {} } }, - "summary": "Create team", - "description": "Creates a team inside an organization. Requires organization.teams.write.", + "summary": "Add Organization member", + "description": "Adds an existing user to a Organization and assigns one or more roles.", "parameters": [ { "name": "organizationId", "in": "path", - "description": "Team identifier", + "description": "OrganizationMember identifier", "required": true, "deprecated": false, "schema": { @@ -41288,11 +43681,11 @@ } ], "requestBody": { - "description": "The new Team resource", + "description": "The new OrganizationMember resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Team.CreateTeamInput-Organization.write" + "$ref": "#/components/schemas/OrganizationMember.AddOrganizationMemberInput-Organization.write" } } }, @@ -41300,23 +43693,127 @@ } } }, - "/api/organizations/{organizationId}/teams/{teamId}": { - "get": { - "operationId": "getTeam", + "/api/organizations/{organizationId}/members/batch-remove": { + "post": { + "operationId": "batchRemoveOrganizationMembers", "tags": [ - "Teams" + "Organization Members" ], "responses": { "200": { - "description": "Team retrieved", + "description": "OrganizationMember resource created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Team.TeamOutput.jsonld-Organization.read" + "$ref": "#/components/schemas/OrganizationMember.RemoveOrganizationMembersOutput.jsonld-Organization.read" + } + } + }, + "links": {} + }, + "400": { + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + } + }, + "summary": "Batch remove Organization members", + "description": "Removes several members in one request, reporting removed and failed IDs.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "OrganizationMember identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], + "requestBody": { + "description": "The new OrganizationMember resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/OrganizationMember.RemoveOrganizationMembersInput-Organization.write" } } }, + "required": true + } + } + }, + "/api/organizations/{organizationId}/members/me": { + "delete": { + "operationId": "leaveOrganization", + "tags": [ + "Organization Members" + ], + "responses": { + "204": { + "description": "Membership deactivated" + }, "403": { "description": "Forbidden", "content": { @@ -41339,16 +43836,61 @@ "links": {} }, "404": { - "description": "Organization or team not found" + "description": "Organization not found, or caller is not an active member" + }, + "409": { + "description": "Caller is the organization's current owner, or is the last active administrator" } }, - "summary": "Get team", - "description": "Returns a single team by identifier. Requires organization.teams.read.", + "summary": "Leave Organization", + "description": "Deactivates the authenticated user's own membership (self-removal). The organization's current owner cannot leave — transfer ownership first via POST /organizations/{id}/transfer-ownership. Refused when leaving would strip the organization of its last active administrator.", "parameters": [ { "name": "organizationId", "in": "path", - "description": "Team identifier", + "description": "OrganizationMember identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ] + } + }, + "/api/organizations/{organizationId}/members/{memberId}": { + "get": { + "operationId": "getOrganizationMember", + "tags": [ + "Organization Members" + ], + "responses": { + "200": { + "description": "OrganizationMember resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/OrganizationMember.OrganizationMemberOutput.jsonld-Organization.read" + } + } + } + }, + "403": { + "description": "Insufficient permissions" + }, + "404": { + "description": "Organization not found, or member not found in this organization" + } + }, + "summary": "Get Organization member", + "description": "Resolves a single organization member by identifier.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "OrganizationMember identifier", "required": true, "deprecated": false, "schema": { @@ -41358,9 +43900,9 @@ "explode": false }, { - "name": "teamId", + "name": "memberId", "in": "path", - "description": "Team identifier", + "description": "OrganizationMember identifier", "required": true, "deprecated": false, "schema": { @@ -41372,28 +43914,31 @@ ] }, "delete": { - "operationId": "deleteTeam", + "operationId": "removeOrganizationMember", "tags": [ - "Teams" + "Organization Members" ], "responses": { "204": { - "description": "Team deleted" + "description": "Member removed" + }, + "400": { + "description": "Invalid identifier" }, "403": { "description": "Insufficient permissions" }, "404": { - "description": "Organization or team not found" + "description": "Organization or member not found" } }, - "summary": "Delete team", - "description": "Permanently deletes a team. All team member assignments are removed. Requires organization.teams.manage.", + "summary": "Remove Organization member", + "description": "Deactivates an organization member. The membership record is retained for audit purposes.", "parameters": [ { "name": "organizationId", "in": "path", - "description": "Team identifier", + "description": "OrganizationMember identifier", "required": true, "deprecated": false, "schema": { @@ -41403,9 +43948,9 @@ "explode": false }, { - "name": "teamId", + "name": "memberId", "in": "path", - "description": "Team identifier", + "description": "OrganizationMember identifier", "required": true, "deprecated": false, "schema": { @@ -41415,19 +43960,21 @@ "explode": false } ] - }, - "patch": { - "operationId": "updateTeam", + } + }, + "/api/organizations/{organizationId}/members/{memberId}/reactivate": { + "post": { + "operationId": "reactivateOrganizationMember", "tags": [ - "Teams" + "Organization Members" ], "responses": { "200": { - "description": "Team resource updated", + "description": "Member reactivated", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Team.TeamOutput.jsonld-Organization.read" + "$ref": "#/components/schemas/OrganizationMember.OrganizationMemberOutput.jsonld-Organization.read" } } }, @@ -41455,46 +44002,13 @@ "links": {} }, "403": { - "description": "Forbidden", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "links": {} + "description": "Insufficient permissions" }, "404": { - "description": "Not found", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "links": {} + "description": "Organization not found, or member not found in this organization" + }, + "409": { + "description": "Organization is archived, member is already active, or the plan's member cap has been reached" }, "422": { "description": "An error occurred", @@ -41518,13 +44032,13 @@ "links": {} } }, - "summary": "Update team", - "description": "Renames or redescribes a team. Requires organization.teams.write.", + "summary": "Reactivate Organization member", + "description": "Reactivates a previously deactivated (removed) organization member. Subject to the same plan member cap as adding a new member.", "parameters": [ { "name": "organizationId", "in": "path", - "description": "Team identifier", + "description": "OrganizationMember identifier", "required": true, "deprecated": false, "schema": { @@ -41534,9 +44048,9 @@ "explode": false }, { - "name": "teamId", + "name": "memberId", "in": "path", - "description": "Team identifier", + "description": "OrganizationMember identifier", "required": true, "deprecated": false, "schema": { @@ -41545,34 +44059,23 @@ "style": "simple", "explode": false } - ], - "requestBody": { - "description": "The updated Team resource", - "content": { - "application/merge-patch+json": { - "schema": { - "$ref": "#/components/schemas/Team.UpdateTeamInput-Organization.write.jsonMergePatch" - } - } - }, - "required": true - } + ] } }, - "/api/organizations/{organizationId}/teams/{teamId}/members": { + "/api/organizations/{organizationId}/permissions": { "get": { - "operationId": "listTeamMembers", + "operationId": "listOrganizationPermissions", "tags": [ - "Teams" + "Organization Permissions" ], "responses": { "200": { - "description": "Team collection", + "description": "OrganizationPermission collection", "content": { "application/ld+json": { "schema": { "type": "object", - "description": "Team.TeamMemberOutput.jsonld-Organization.read collection.", + "description": "OrganizationPermission.OrganizationPermissionOutput.jsonld-Organization.read collection.", "allOf": [ { "$ref": "#/components/schemas/HydraCollectionBaseSchema" @@ -41586,7 +44089,7 @@ "member": { "type": "array", "items": { - "$ref": "#/components/schemas/Team.TeamMemberOutput.jsonld-Organization.read" + "$ref": "#/components/schemas/OrganizationPermission.OrganizationPermissionOutput.jsonld-Organization.read" } } } @@ -41618,13 +44121,13 @@ "links": {} } }, - "summary": "List team members", - "description": "Lists members assigned to a team. Requires organization.teams.read.", + "summary": "List available Organization permissions", + "description": "Lists all system-defined permissions that can be assigned to organization roles.", "parameters": [ { "name": "organizationId", "in": "path", - "description": "Team identifier", + "description": "OrganizationPermission identifier", "required": true, "deprecated": false, "schema": { @@ -41634,9 +44137,98 @@ "explode": false }, { - "name": "teamId", + "name": "page", + "in": "query", + "description": "The collection page number", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 1 + }, + "style": "form", + "explode": true + } + ] + } + }, + "/api/organizations/{organizationId}/members/{memberId}/roles": { + "put": { + "operationId": "setOrganizationMemberRoles", + "tags": [ + "Organization Roles" + ], + "responses": { + "200": { + "description": "Member roles replaced", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/OrganizationRole.OrganizationMemberOutput.jsonld-Organization.read" + } + } + }, + "links": {} + }, + "400": { + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "403": { + "description": "Insufficient permissions, or attempting to grant a permission the caller does not hold" + }, + "404": { + "description": "Organization, member, or one of the requested roles not found" + }, + "409": { + "description": "Would strip the organization of its last administrator" + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + } + }, + "summary": "Replace member roles", + "description": "Replaces the member's entire role set in one call. Roles being granted go through the privilege-escalation guard; roles being revoked go through the last-administrator lockout guard.", + "parameters": [ + { + "name": "organizationId", "in": "path", - "description": "Team identifier", + "description": "OrganizationRole identifier", "required": true, "deprecated": false, "schema": { @@ -41646,39 +44238,49 @@ "explode": false }, { - "name": "page", - "in": "query", - "description": "The collection page number", - "required": false, + "name": "memberId", + "in": "path", + "description": "OrganizationRole identifier", + "required": true, "deprecated": false, "schema": { - "type": "integer", - "default": 1 + "type": "string" }, - "style": "form", - "explode": true + "style": "simple", + "explode": false } - ] + ], + "requestBody": { + "description": "The updated OrganizationRole resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/OrganizationRole.SetOrganizationMemberRolesInput-Organization.write" + } + } + }, + "required": true + } }, "post": { - "operationId": "addTeamMember", + "operationId": "assignOrganizationRoleToMember", "tags": [ - "Teams" + "Organization Roles" ], "responses": { "201": { - "description": "Member added to the team", + "description": "OrganizationRole resource created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Team.TeamMemberOutput.jsonld-Organization.read" + "$ref": "#/components/schemas/OrganizationRole.OrganizationMemberOutput.jsonld-Organization.read" } } }, "links": {} }, "400": { - "description": "Member is not active in this organization, or already part of the team", + "description": "Invalid input", "content": { "application/ld+json": { "schema": { @@ -41699,10 +44301,25 @@ "links": {} }, "403": { - "description": "Insufficient permissions" - }, - "404": { - "description": "Organization, team, or member not found" + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} }, "422": { "description": "An error occurred", @@ -41726,13 +44343,13 @@ "links": {} } }, - "summary": "Add team member", - "description": "Adds an active organization member to a team. Requires organization.teams.write.", + "summary": "Assign role to member", + "description": "Assigns an existing Organization role to an existing Organization member.", "parameters": [ { "name": "organizationId", "in": "path", - "description": "Team identifier", + "description": "OrganizationRole identifier", "required": true, "deprecated": false, "schema": { @@ -41742,9 +44359,9 @@ "explode": false }, { - "name": "teamId", + "name": "memberId", "in": "path", - "description": "Team identifier", + "description": "OrganizationRole identifier", "required": true, "deprecated": false, "schema": { @@ -41755,11 +44372,11 @@ } ], "requestBody": { - "description": "The new Team resource", + "description": "The new OrganizationRole resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Team.AddTeamMemberInput-Organization.write" + "$ref": "#/components/schemas/OrganizationRole.AssignOrganizationRoleInput-Organization.write" } } }, @@ -41767,30 +44384,33 @@ } } }, - "/api/organizations/{organizationId}/teams/{teamId}/members/{memberId}": { + "/api/organizations/{organizationId}/members/{memberId}/roles/{roleId}": { "delete": { - "operationId": "removeTeamMember", + "operationId": "removeOrganizationRoleFromMember", "tags": [ - "Teams" + "Organization Roles" ], "responses": { "204": { - "description": "Member removed from the team" + "description": "Role removed from member" + }, + "400": { + "description": "Invalid identifier" }, "403": { "description": "Insufficient permissions" }, "404": { - "description": "Organization, team, or membership not found" + "description": "Organization, member, or role not found" } }, - "summary": "Remove team member", - "description": "Removes a member from a team. Requires organization.teams.write.", + "summary": "Remove role from member", + "description": "Removes a role assignment from an organization member.", "parameters": [ { "name": "organizationId", "in": "path", - "description": "Team identifier", + "description": "OrganizationRole identifier", "required": true, "deprecated": false, "schema": { @@ -41800,9 +44420,9 @@ "explode": false }, { - "name": "teamId", + "name": "memberId", "in": "path", - "description": "Team identifier", + "description": "OrganizationRole identifier", "required": true, "deprecated": false, "schema": { @@ -41812,9 +44432,9 @@ "explode": false }, { - "name": "memberId", + "name": "roleId", "in": "path", - "description": "Team identifier", + "description": "OrganizationRole identifier", "required": true, "deprecated": false, "schema": { @@ -41826,20 +44446,20 @@ ] } }, - "/api/tenants": { + "/api/organizations/{organizationId}/roles": { "get": { - "operationId": "tenant_list", + "operationId": "listOrganizationRoles", "tags": [ - "Tenants" + "Organization Roles" ], "responses": { "200": { - "description": "List of tenants retrieved successfully", + "description": "OrganizationRole collection", "content": { "application/ld+json": { "schema": { "type": "object", - "description": "Tenant.TenantOutput.jsonld-tenant.read collection.", + "description": "OrganizationRole.OrganizationRoleOutput.jsonld-Organization.read collection.", "allOf": [ { "$ref": "#/components/schemas/HydraCollectionBaseSchema" @@ -41853,7 +44473,7 @@ "member": { "type": "array", "items": { - "$ref": "#/components/schemas/Tenant.TenantOutput.jsonld-tenant.read" + "$ref": "#/components/schemas/OrganizationRole.OrganizationRoleOutput.jsonld-Organization.read" } } } @@ -41863,21 +44483,48 @@ } } }, - "401": { - "description": "Authentication required" - }, "403": { - "description": "Insufficient permissions" - } - }, - "summary": "List tenants", - "description": "Returns a list of all tenants. Requires tenants.read permission.", - "parameters": [ - { - "name": "page", - "in": "query", - "description": "The collection page number", - "required": false, + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + } + }, + "summary": "List Organization roles", + "description": "Lists all roles defined for a Organization. Real pagination (`page`/`itemsPerPage`, default 30); `totalItems` reflects the count AFTER search filtering. Supports `search` (matched against the role name) and `order[name|isSystem|createdAt]=asc|desc` (default `order[name]=asc`).", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "OrganizationRole identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "page", + "in": "query", + "description": "The collection page number", + "required": false, "deprecated": false, "schema": { "type": "integer", @@ -41901,32 +44548,27 @@ "style": "form", "explode": true } - ], - "security": [ - { - "bearerAuth": [] - } ] }, "post": { - "operationId": "tenant_create", + "operationId": "createOrganizationRole", "tags": [ - "Tenants" + "Organization Roles" ], "responses": { "201": { - "description": "Tenant created successfully", + "description": "OrganizationRole resource created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Tenant.TenantOutput.jsonld-tenant.read" + "$ref": "#/components/schemas/OrganizationRole.OrganizationRoleOutput.jsonld-Organization.read" } } }, "links": {} }, "400": { - "description": "Invalid request - validation failed", + "description": "Invalid input", "content": { "application/ld+json": { "schema": { @@ -41946,11 +44588,26 @@ }, "links": {} }, - "401": { - "description": "Authentication required" - }, "403": { - "description": "Insufficient permissions" + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} }, "422": { "description": "An error occurred", @@ -41974,61 +44631,78 @@ "links": {} } }, - "summary": "Create tenant", - "description": "Create a new tenant with custom OAuth2 settings.", - "parameters": [], + "summary": "Create Organization role", + "description": "Creates a custom role inside a Organization with explicit permissions.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "OrganizationRole identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], "requestBody": { - "description": "The new Tenant resource", + "description": "The new OrganizationRole resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Tenant.TenantInput-tenant.write" + "$ref": "#/components/schemas/OrganizationRole.CreateOrganizationRoleInput-Organization.write" } } }, "required": true - }, - "security": [ - { - "bearerAuth": [] - } - ] + } } }, - "/api/tenants/{id}": { + "/api/organizations/{organizationId}/roles/{roleId}": { "get": { - "operationId": "tenant_get", + "operationId": "getOrganizationRole", "tags": [ - "Tenants" + "Organization Roles" ], "responses": { "200": { - "description": "Tenant retrieved successfully", + "description": "Role retrieved", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Tenant.TenantOutput.jsonld-tenant.read" + "$ref": "#/components/schemas/OrganizationRole.OrganizationRoleOutput.jsonld-Organization.read" } } } }, - "401": { - "description": "Authentication required" - }, "403": { "description": "Insufficient permissions" }, "404": { - "description": "Tenant not found" + "description": "Organization or role not found, or the role belongs to another organization" } }, - "summary": "Get tenant", - "description": "Get details of a specific tenant.", + "summary": "Get Organization role", + "description": "Returns a single organization role, including memberCount (the number of ACTIVE members currently assigned). Requires the organization.roles.read permission.", "parameters": [ { - "name": "id", + "name": "organizationId", "in": "path", - "description": "Tenant identifier", + "description": "OrganizationRole identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "roleId", + "in": "path", + "description": "OrganizationRole identifier", "required": true, "deprecated": false, "schema": { @@ -42037,32 +44711,75 @@ "style": "simple", "explode": false } + ] + }, + "delete": { + "operationId": "deleteOrganizationRole", + "tags": [ + "Organization Roles" ], - "security": [ + "responses": { + "204": { + "description": "Role deleted" + }, + "400": { + "description": "Invalid identifier or system role" + }, + "403": { + "description": "Insufficient permissions" + }, + "404": { + "description": "Organization or role not found" + } + }, + "summary": "Delete Organization role", + "description": "Permanently deletes a custom role. All member role assignments for this role are removed. System roles cannot be deleted.", + "parameters": [ { - "bearerAuth": [] + "name": "organizationId", + "in": "path", + "description": "OrganizationRole identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "roleId", + "in": "path", + "description": "OrganizationRole identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false } ] }, - "put": { - "operationId": "tenant_replace", + "patch": { + "operationId": "updateOrganizationRole", "tags": [ - "Tenants" + "Organization Roles" ], "responses": { "200": { - "description": "Tenant replaced successfully", + "description": "Role updated", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Tenant.TenantOutput.jsonld-tenant.read" + "$ref": "#/components/schemas/OrganizationRole.OrganizationRoleOutput.jsonld-Organization.read" } } }, "links": {} }, "400": { - "description": "Invalid request - validation failed", + "description": "Invalid payload, or the role is a system role", "content": { "application/ld+json": { "schema": { @@ -42082,14 +44799,14 @@ }, "links": {} }, - "401": { - "description": "Authentication required" - }, "403": { - "description": "Insufficient permissions" + "description": "Insufficient permissions, or granting a permission the caller does not hold" }, "404": { - "description": "Tenant not found" + "description": "Organization or role not found, or the role belongs to another organization" + }, + "409": { + "description": "The change would leave the organization without an administrator" }, "422": { "description": "An error occurred", @@ -42113,13 +44830,13 @@ "links": {} } }, - "summary": "Replace tenant", - "description": "Replace a tenant by ID. Requires tenants.update permission.", + "summary": "Update Organization role permissions", + "description": "Updates the permissions assigned to a custom organization role, and optionally renames it.", "parameters": [ { - "name": "id", + "name": "organizationId", "in": "path", - "description": "Tenant identifier", + "description": "OrganizationRole identifier", "required": true, "deprecated": false, "schema": { @@ -42127,51 +44844,11 @@ }, "style": "simple", "explode": false - } - ], - "requestBody": { - "description": "The updated Tenant resource", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Tenant.TenantInput-tenant.write" - } - } - }, - "required": true - }, - "security": [ - { - "bearerAuth": [] - } - ] - }, - "delete": { - "operationId": "tenant_delete", - "tags": [ - "Tenants" - ], - "responses": { - "204": { - "description": "Tenant deleted successfully" }, - "401": { - "description": "Authentication required" - }, - "403": { - "description": "Insufficient permissions" - }, - "404": { - "description": "Tenant not found" - } - }, - "summary": "Delete tenant", - "description": "Delete a tenant by ID. Requires tenants.delete permission.", - "parameters": [ { - "name": "id", + "name": "roleId", "in": "path", - "description": "Tenant identifier", + "description": "OrganizationRole identifier", "required": true, "deprecated": false, "schema": { @@ -42181,31 +44858,39 @@ "explode": false } ], - "security": [ - { - "bearerAuth": [] - } - ] - }, - "patch": { - "operationId": "tenant_update", + "requestBody": { + "description": "The updated OrganizationRole resource", + "content": { + "application/merge-patch+json": { + "schema": { + "$ref": "#/components/schemas/OrganizationRole.UpdateOrganizationRoleInput-Organization.write.jsonMergePatch" + } + } + }, + "required": true + } + } + }, + "/api/organizations/invitations/{invitationId}/accept": { + "post": { + "operationId": "organization_join_accept_invitation_by_id", "tags": [ - "Tenants" + "Organization access" ], "responses": { "200": { - "description": "Tenant updated successfully", + "description": "OrganizationJoin resource created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Tenant.TenantOutput.jsonld-tenant.read" + "$ref": "#/components/schemas/OrganizationJoin.OrganizationJoinCompletedOutput.jsonld-Organization.read" } } }, "links": {} }, "400": { - "description": "Invalid request - validation failed", + "description": "Invalid input", "content": { "application/ld+json": { "schema": { @@ -42225,14 +44910,26 @@ }, "links": {} }, - "401": { - "description": "Authentication required" - }, "403": { - "description": "Insufficient permissions" - }, - "404": { - "description": "Tenant not found" + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} }, "422": { "description": "An error occurred", @@ -42256,13 +44953,13 @@ "links": {} } }, - "summary": "Update tenant", - "description": "Update a tenant by ID. Requires tenants.update permission.", + "summary": "accept invitation by id", + "description": "accept invitation by id", "parameters": [ { - "name": "id", + "name": "invitationId", "in": "path", - "description": "Tenant identifier", + "description": "OrganizationJoin identifier", "required": true, "deprecated": false, "schema": { @@ -42271,45 +44968,49 @@ "style": "simple", "explode": false } - ], - "requestBody": { - "description": "The updated Tenant resource", - "content": { - "application/merge-patch+json": { - "schema": { - "$ref": "#/components/schemas/Tenant.TenantInput-tenant.write.jsonMergePatch" - } - } - }, - "required": true - }, - "security": [ - { - "bearerAuth": [] - } ] } }, - "/api/tenants/{id}/activate": { - "post": { - "operationId": "tenant_activate", + "/api/organizations/join-options": { + "get": { + "operationId": "organization_join_join_options", "tags": [ - "Tenants" + "Organization access" ], "responses": { "200": { - "description": "Tenant activated successfully", + "description": "OrganizationJoin resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Tenant.TenantOutput.jsonld-tenant.read" + "$ref": "#/components/schemas/OrganizationJoin.OrganizationJoinOptionsOutput.jsonld-Organization.read" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" } } }, "links": {} }, - "400": { - "description": "Invalid input", + "404": { + "description": "Not found", "content": { "application/ld+json": { "schema": { @@ -42328,74 +45029,91 @@ } }, "links": {} - }, - "401": { - "description": "Authentication required" + } + }, + "summary": "join options", + "description": "join options", + "parameters": [] + } + }, + "/api/organizations/join-requests": { + "get": { + "operationId": "organization_join_my_requests", + "tags": [ + "Organization access" + ], + "responses": { + "200": { + "description": "OrganizationJoin resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/OrganizationJoin.OrganizationJoinRequestsOutput.jsonld-Organization.read" + } + } + } }, "403": { - "description": "Insufficient permissions" + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} }, "404": { - "description": "Tenant not found" - }, - "422": { - "description": "An error occurred", + "description": "Not found", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation.jsonld" + "$ref": "#/components/schemas/Error.jsonld" } }, "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation" + "$ref": "#/components/schemas/Error" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation" + "$ref": "#/components/schemas/Error" } } }, "links": {} } }, - "summary": "Activate tenant", - "description": "Activate a tenant by ID. Requires tenants.update permission.", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "Tenant identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - } - ], - "security": [ - { - "bearerAuth": [] - } - ] + "summary": "my requests", + "description": "my requests", + "parameters": [] } }, - "/api/tenants/{id}/deactivate": { + "/api/organizations/join-requests/{requestId}/cancel": { "post": { - "operationId": "tenant_deactivate", + "operationId": "organization_join_cancel_request", "tags": [ - "Tenants" + "Organization access" ], "responses": { "200": { - "description": "Tenant deactivated successfully", + "description": "OrganizationJoin resource created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Tenant.TenantOutput.jsonld-tenant.read" + "$ref": "#/components/schemas/OrganizationJoin.OrganizationJoinRequestOutput.jsonld-Organization.read" } } }, @@ -42422,14 +45140,26 @@ }, "links": {} }, - "401": { - "description": "Authentication required" - }, "403": { - "description": "Insufficient permissions" - }, - "404": { - "description": "Tenant not found" + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} }, "422": { "description": "An error occurred", @@ -42453,13 +45183,13 @@ "links": {} } }, - "summary": "Deactivate tenant", - "description": "Deactivate a tenant by ID. Requires tenants.update permission.", + "summary": "cancel request", + "description": "cancel request", "parameters": [ { - "name": "id", + "name": "requestId", "in": "path", - "description": "Tenant identifier", + "description": "OrganizationJoin identifier", "required": true, "deprecated": false, "schema": { @@ -42468,115 +45198,105 @@ "style": "simple", "explode": false } - ], - "security": [ - { - "bearerAuth": [] - } ] } }, - "/api/trusted-devices": { + "/api/organizations/{organizationId}/access-policy": { "get": { - "operationId": "trusted_device_list", + "operationId": "organization_join_access_policy", "tags": [ - "Trusted Devices" + "Organization access" ], "responses": { "200": { - "description": "List of trusted devices retrieved successfully", + "description": "OrganizationJoin resource", "content": { "application/ld+json": { "schema": { - "type": "object", - "description": "TrustedDevice.TrustedDeviceOutput.jsonld-trusted_device.read collection.", - "allOf": [ - { - "$ref": "#/components/schemas/HydraCollectionBaseSchema" - }, - { - "type": "object", - "required": [ - "member" - ], - "properties": { - "member": { - "type": "array", - "items": { - "$ref": "#/components/schemas/TrustedDevice.TrustedDeviceOutput.jsonld-trusted_device.read" - } - } - } - } - ] + "$ref": "#/components/schemas/OrganizationJoin.OrganizationAccessPolicyOutput.jsonld-Organization.read" } } } }, - "401": { - "description": "Authentication required" - }, "403": { - "description": "Insufficient permissions" + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "404": { + "description": "Not found", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} } }, - "summary": "List trusted devices", - "description": "List all trusted devices for the current user.", + "summary": "access policy", + "description": "access policy", "parameters": [ { - "name": "page", - "in": "query", - "description": "The collection page number", - "required": false, - "deprecated": false, - "schema": { - "type": "integer", - "default": 1 - }, - "style": "form", - "explode": true - }, - { - "name": "itemsPerPage", - "in": "query", - "description": "The number of items per page", - "required": false, + "name": "organizationId", + "in": "path", + "description": "OrganizationJoin identifier", + "required": true, "deprecated": false, "schema": { - "type": "integer", - "default": 30, - "minimum": 0, - "maximum": 100 + "type": "string" }, - "style": "form", - "explode": true - } - ], - "security": [ - { - "bearerAuth": [] + "style": "simple", + "explode": false } ] }, - "post": { - "operationId": "trusted_device_trust", + "patch": { + "operationId": "organization_join_update_access_policy", "tags": [ - "Trusted Devices" + "Organization access" ], "responses": { - "201": { - "description": "Device trusted successfully", + "200": { + "description": "OrganizationJoin resource updated", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/TrustedDevice.TrustDeviceOutput.jsonld-trusted_device.read" + "$ref": "#/components/schemas/OrganizationJoin.OrganizationAccessPolicyOutput.jsonld-Organization.read" } } }, "links": {} }, "400": { - "description": "Invalid request", + "description": "Invalid input", "content": { "application/ld+json": { "schema": { @@ -42596,57 +45316,29 @@ }, "links": {} }, - "401": { - "description": "Authentication required" - }, "403": { - "description": "Insufficient permissions" - }, - "422": { - "description": "An error occurred", + "description": "Forbidden", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation.jsonld" + "$ref": "#/components/schemas/Error.jsonld" } }, "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation" + "$ref": "#/components/schemas/Error" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation" + "$ref": "#/components/schemas/Error" } } }, "links": {} - } - }, - "summary": "Trust device", - "description": "Register the current device as trusted.", - "parameters": [], - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/api/trusted-devices/revoke-all": { - "post": { - "operationId": "trusted_device_revoke_all", - "tags": [ - "Trusted Devices" - ], - "responses": { - "204": { - "description": "All devices revoked successfully", - "links": {} }, - "400": { - "description": "Invalid input", + "404": { + "description": "Not found", "content": { "application/ld+json": { "schema": { @@ -42666,12 +45358,6 @@ }, "links": {} }, - "401": { - "description": "Authentication required" - }, - "403": { - "description": "Insufficient permissions" - }, "422": { "description": "An error occurred", "content": { @@ -42694,43 +45380,13 @@ "links": {} } }, - "summary": "Revoke all trusted devices", - "description": "Revoke trust for all devices.", - "parameters": [], - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/api/trusted-devices/{id}": { - "delete": { - "operationId": "trusted_device_revoke", - "tags": [ - "Trusted Devices" - ], - "responses": { - "204": { - "description": "Device revoked successfully" - }, - "401": { - "description": "Authentication required" - }, - "403": { - "description": "Insufficient permissions" - }, - "404": { - "description": "Device not found" - } - }, - "summary": "Revoke trusted device", - "description": "Revoke trust for a specific device.", + "summary": "update access policy", + "description": "update access policy", "parameters": [ { - "name": "id", + "name": "organizationId", "in": "path", - "description": "TrustedDevice identifier", + "description": "OrganizationJoin identifier", "required": true, "deprecated": false, "schema": { @@ -42740,35 +45396,39 @@ "explode": false } ], - "security": [ - { - "bearerAuth": [] - } - ] + "requestBody": { + "description": "The updated OrganizationJoin resource", + "content": { + "application/merge-patch+json": { + "schema": { + "$ref": "#/components/schemas/OrganizationJoin.OrganizationAccessPolicyInput-Organization.write.jsonMergePatch" + } + } + }, + "required": true + } } }, - "/api/me": { - "get": { - "operationId": "user_get_current_profile", + "/api/organizations/{organizationId}/domains": { + "post": { + "operationId": "organization_join_add_domain", "tags": [ - "Users" + "Organization access" ], "responses": { "200": { - "description": "Current user profile retrieved successfully", + "description": "OrganizationJoin resource created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/CurrentUserProfile.CurrentUserProfileOutput.jsonld-user.read" + "$ref": "#/components/schemas/OrganizationJoin.OrganizationDomainOutput.jsonld-Organization.read" } } - } - }, - "401": { - "description": "Authentication required" + }, + "links": {} }, - "403": { - "description": "Forbidden", + "400": { + "description": "Invalid input", "content": { "application/ld+json": { "schema": { @@ -42788,38 +45448,8 @@ }, "links": {} }, - "404": { - "description": "Authenticated user not found" - } - }, - "summary": "Get current user profile", - "description": "Returns the authenticated user profile with resolved global roles and permissions.", - "parameters": [], - "security": [ - { - "bearerAuth": [] - } - ] - }, - "patch": { - "operationId": "user_update_current_profile", - "tags": [ - "Users" - ], - "responses": { - "200": { - "description": "Current user profile updated successfully", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/CurrentUserProfile.CurrentUserProfileOutput.jsonld-user.read" - } - } - }, - "links": {} - }, - "400": { - "description": "Invalid request - validation failed", + "403": { + "description": "Forbidden", "content": { "application/ld+json": { "schema": { @@ -42839,15 +45469,6 @@ }, "links": {} }, - "401": { - "description": "Authentication required" - }, - "403": { - "description": "Insufficient permissions" - }, - "404": { - "description": "Authenticated user not found" - }, "422": { "description": "An error occurred", "content": { @@ -42870,47 +45491,47 @@ "links": {} } }, - "summary": "Update current user profile", - "description": "Partially updates the authenticated user profile (first name, last name, preferred display language). Requires profile.update permission.", - "parameters": [], + "summary": "add domain", + "description": "add domain", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "OrganizationJoin identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], "requestBody": { - "description": "The updated CurrentUserProfile resource", + "description": "The new OrganizationJoin resource", "content": { - "application/merge-patch+json": { + "application/ld+json": { "schema": { - "$ref": "#/components/schemas/CurrentUserProfile.CurrentUserProfileInput-user.write.jsonMergePatch" + "$ref": "#/components/schemas/OrganizationJoin.OrganizationDomainInput-Organization.write" } } }, "required": true - }, - "security": [ - { - "bearerAuth": [] - } - ] + } } }, - "/api/me/avatar": { - "put": { - "operationId": "user_upload_current_avatar", + "/api/organizations/{organizationId}/domains/{domainId}": { + "delete": { + "operationId": "organization_join_remove_domain", "tags": [ - "Users" + "Organization access" ], "responses": { "200": { - "description": "Current user avatar uploaded successfully", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/CurrentUserProfile.CurrentUserProfileOutput.jsonld-user.read" - } - } - }, - "links": {} + "description": "OrganizationJoin resource deleted" }, - "400": { - "description": "Invalid input", + "403": { + "description": "Forbidden", "content": { "application/ld+json": { "schema": { @@ -42930,81 +45551,71 @@ }, "links": {} }, - "401": { - "description": "Authentication required" - }, - "403": { - "description": "Insufficient permissions" - }, "404": { - "description": "Authenticated user not found" - }, - "422": { - "description": "Invalid file - missing, too large, or unsupported MIME type", + "description": "Not found", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation.jsonld" + "$ref": "#/components/schemas/Error.jsonld" } }, "application/problem+json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation" + "$ref": "#/components/schemas/Error" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/ConstraintViolation" + "$ref": "#/components/schemas/Error" } } }, "links": {} } }, - "summary": "Upload current user avatar", - "description": "Replaces the authenticated user avatar. Accepted formats: JPEG, PNG, WebP, GIF (max 5 MB). Requires profile.update permission.", - "parameters": [], - "requestBody": { - "description": "Avatar image file (JPEG, PNG, WebP or GIF, max 5 MB)", - "content": { - "multipart/form-data": { - "schema": { - "type": "object", - "required": [ - "avatar" - ], - "properties": { - "avatar": { - "type": "string", - "format": "binary", - "description": "Image file (JPEG, PNG, WebP or GIF), max 5 MB" - } - } - } - } + "summary": "remove domain", + "description": "remove domain", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "OrganizationJoin identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false }, - "required": true - }, - "security": [ { - "bearerAuth": [] + "name": "domainId", + "in": "path", + "description": "OrganizationJoin identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false } ] } }, - "/api/me/deactivate": { + "/api/organizations/{organizationId}/domains/{domainId}/verify": { "post": { - "operationId": "user_deactivate_current", + "operationId": "organization_join_verify_domain", "tags": [ - "Users" + "Organization access" ], "responses": { "200": { - "description": "Account deactivated successfully — all sessions revoked", + "description": "OrganizationJoin resource created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/CurrentUserProfile.CurrentUserProfileOutput.jsonld-user.read" + "$ref": "#/components/schemas/OrganizationJoin.OrganizationDomainOutput.jsonld-Organization.read" } } }, @@ -43031,14 +45642,26 @@ }, "links": {} }, - "401": { - "description": "Authentication required" - }, "403": { - "description": "Insufficient permissions" - }, - "404": { - "description": "Authenticated user not found" + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} }, "422": { "description": "An error occurred", @@ -43062,36 +45685,56 @@ "links": {} } }, - "summary": "Deactivate current user account", - "description": "Deactivates the authenticated user own account and revokes all of their active sessions, signing them out everywhere. Idempotent. Organization memberships are left untouched. Reactivation is admin-only (POST /users/{id}/activate) — there is no self-service reactivation. Requires profile.update permission.", - "parameters": [], - "security": [ + "summary": "verify domain", + "description": "verify domain", + "parameters": [ { - "bearerAuth": [] + "name": "organizationId", + "in": "path", + "description": "OrganizationJoin identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "domainId", + "in": "path", + "description": "OrganizationJoin identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false } ] } }, - "/api/me/email-change": { + "/api/organizations/{organizationId}/join": { "post": { - "operationId": "user_email_change_request", + "operationId": "organization_join_join", "tags": [ - "Users" + "Organization access" ], "responses": { - "202": { - "description": "Confirmation link sent to the new address", + "200": { + "description": "OrganizationJoin resource created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/EmailChange.RequestEmailChangeOutput.jsonld-email_change.read" + "$ref": "#/components/schemas/OrganizationJoin.OrganizationJoinCompletedOutput.jsonld-Organization.read" } } }, "links": {} }, "400": { - "description": "Invalid request - missing or malformed fields", + "description": "Invalid input", "content": { "application/ld+json": { "schema": { @@ -43111,9 +45754,6 @@ }, "links": {} }, - "401": { - "description": "Authentication required" - }, "403": { "description": "Forbidden", "content": { @@ -43135,11 +45775,8 @@ }, "links": {} }, - "409": { - "description": "This email address cannot be used" - }, "422": { - "description": "Current password is incorrect", + "description": "An error occurred", "content": { "application/ld+json": { "schema": { @@ -43158,42 +45795,42 @@ } }, "links": {} - }, - "429": { - "description": "Too many email change requests" } }, - "summary": "Request email change", - "description": "Verifies the current password, then sends a confirmation link (1 h validity) to the new address and an alert to the current address. A new request replaces any pending one.", - "parameters": [], - "requestBody": { - "description": "The new EmailChange resource", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/EmailChange.RequestEmailChangeInput-email_change.write" - } - } - }, - "required": true - }, - "security": [ + "summary": "join", + "description": "join", + "parameters": [ { - "bearerAuth": [] + "name": "organizationId", + "in": "path", + "description": "OrganizationJoin identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false } ] - }, - "delete": { - "operationId": "user_email_change_cancel", + } + }, + "/api/organizations/{organizationId}/join-requests": { + "get": { + "operationId": "organization_join_organization_requests", "tags": [ - "Users" + "Organization access" ], "responses": { - "204": { - "description": "Pending request cancelled (or nothing was pending)" - }, - "401": { - "description": "Authentication required" + "200": { + "description": "OrganizationJoin resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/OrganizationJoin.OrganizationJoinRequestsOutput.jsonld-Organization.read" + } + } + } }, "403": { "description": "Forbidden", @@ -43238,36 +45875,42 @@ "links": {} } }, - "summary": "Cancel pending email change", - "description": "Cancels the pending email change request of the authenticated user. Idempotent: answers 204 whether or not a request was pending.", - "parameters": [], - "security": [ + "summary": "organization requests", + "description": "organization requests", + "parameters": [ { - "bearerAuth": [] + "name": "organizationId", + "in": "path", + "description": "OrganizationJoin identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false } ] - } - }, - "/api/me/email-change/confirm": { + }, "post": { - "operationId": "user_email_change_confirm", + "operationId": "organization_join_request", "tags": [ - "Users" + "Organization access" ], "responses": { "200": { - "description": "Email changed — all sessions have been revoked, sign in again with the new address", + "description": "OrganizationJoin resource created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/EmailChange.ConfirmEmailChangeOutput.jsonld-email_change.read" + "$ref": "#/components/schemas/OrganizationJoin.OrganizationJoinRequestOutput.jsonld-Organization.read" } } }, "links": {} }, "400": { - "description": "Invalid, expired or already-used token", + "description": "Invalid input", "content": { "application/ld+json": { "schema": { @@ -43287,8 +45930,26 @@ }, "links": {} }, - "409": { - "description": "This email address cannot be used" + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} }, "422": { "description": "An error occurred", @@ -43310,47 +45971,46 @@ } }, "links": {} - }, - "429": { - "description": "Too many confirmation attempts" } }, - "summary": "Confirm email change", - "description": "Public. Validates the confirmation token received at the new address, applies the change, and revokes every session and OAuth token of the user (the sign-in identifier changed). The token is single-use and expires after 1 hour.", - "parameters": [], - "requestBody": { - "description": "The new EmailChange resource", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/EmailChange.ConfirmEmailChangeInput-email_change.write" - } - } - }, - "required": true - } + "summary": "request", + "description": "request", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "OrganizationJoin identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ] } }, - "/api/me/password/confirm": { + "/api/organizations/{organizationId}/join-requests/{requestId}/approve": { "post": { - "operationId": "confirm_password_change", + "operationId": "organization_join_approve_request", "tags": [ - "Users" + "Organization access" ], "responses": { "200": { - "description": "Password changed successfully — all sessions have been revoked", + "description": "OrganizationJoin resource created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/PasswordChange.ConfirmPasswordChangeOutput.jsonld-token.read" + "$ref": "#/components/schemas/OrganizationJoin.OrganizationJoinRequestOutput.jsonld-Organization.read" } } }, "links": {} }, "400": { - "description": "Invalid request - missing or invalid parameters", + "description": "Invalid input", "content": { "application/ld+json": { "schema": { @@ -43370,9 +46030,6 @@ }, "links": {} }, - "401": { - "description": "Authentication required, or invalid/expired token/code" - }, "403": { "description": "Forbidden", "content": { @@ -43414,57 +46071,69 @@ } }, "links": {} - }, - "429": { - "description": "Too many confirmation attempts or maximum verification attempts exceeded" } }, - "summary": "Confirm password change", - "description": "Confirms the password change using the challenge token, the OTP code received by email, and the new password. All sessions and OAuth tokens are revoked afterwards.", - "parameters": [], + "summary": "approve request", + "description": "approve request", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "OrganizationJoin identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "requestId", + "in": "path", + "description": "OrganizationJoin identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], "requestBody": { - "description": "The new PasswordChange resource", + "description": "The new OrganizationJoin resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/PasswordChange.ConfirmPasswordChangeInput-password_change.write" + "$ref": "#/components/schemas/OrganizationJoin.ApproveOrganizationJoinRequestInput-Organization.write" } } }, "required": true - }, - "security": [ - { - "bearerAuth": [] - } - ] + } } }, - "/api/me/password/request": { + "/api/organizations/{organizationId}/join-requests/{requestId}/reject": { "post": { - "operationId": "request_password_change", + "operationId": "organization_join_reject_request", "tags": [ - "Users" + "Organization access" ], "responses": { "200": { - "description": "Verification code sent — use the challenge token in the confirm step", + "description": "OrganizationJoin resource created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/PasswordChange.RequestPasswordChangeOutput.jsonld-token.read" + "$ref": "#/components/schemas/OrganizationJoin.OrganizationJoinRequestOutput.jsonld-Organization.read" } } }, - "links": { - "ConfirmChange": { - "operationId": "confirm_password_change", - "description": "Confirm the password change using the token and code" - } - } + "links": {} }, "400": { - "description": "Invalid request - missing current password", + "description": "Invalid input", "content": { "application/ld+json": { "schema": { @@ -43484,9 +46153,6 @@ }, "links": {} }, - "401": { - "description": "Authentication required" - }, "403": { "description": "Forbidden", "content": { @@ -43509,7 +46175,7 @@ "links": {} }, "422": { - "description": "Current password is incorrect", + "description": "An error occurred", "content": { "application/ld+json": { "schema": { @@ -43528,133 +46194,63 @@ } }, "links": {} - }, - "429": { - "description": "Too many password change requests" - } - }, - "summary": "Request password change", - "description": "Verifies the current password and sends an OTP code to the user's email. The returned challenge token is required for the confirm step.", - "parameters": [], - "requestBody": { - "description": "The new PasswordChange resource", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/PasswordChange.RequestPasswordChangeInput-password_change.write" - } - } - }, - "required": true - }, - "security": [ - { - "bearerAuth": [] - } - ] - } - }, - "/api/users": { - "get": { - "operationId": "user_list", - "tags": [ - "Users" - ], - "responses": { - "200": { - "description": "List of users retrieved successfully", - "content": { - "application/ld+json": { - "schema": { - "type": "object", - "description": "User.UserOutput.jsonld-user.read collection.", - "allOf": [ - { - "$ref": "#/components/schemas/HydraCollectionBaseSchema" - }, - { - "type": "object", - "required": [ - "member" - ], - "properties": { - "member": { - "type": "array", - "items": { - "$ref": "#/components/schemas/User.UserOutput.jsonld-user.read" - } - } - } - } - ] - } - } - } - }, - "401": { - "description": "Authentication required" - }, - "403": { - "description": "Insufficient permissions" } }, - "summary": "List users", - "description": "Returns a paginated list of users. Requires users.read permission.", + "summary": "reject request", + "description": "reject request", "parameters": [ { - "name": "page", - "in": "query", - "description": "The collection page number", - "required": false, + "name": "organizationId", + "in": "path", + "description": "OrganizationJoin identifier", + "required": true, "deprecated": false, "schema": { - "type": "integer", - "default": 1 + "type": "string" }, - "style": "form", - "explode": true + "style": "simple", + "explode": false }, { - "name": "itemsPerPage", - "in": "query", - "description": "The number of items per page", - "required": false, + "name": "requestId", + "in": "path", + "description": "OrganizationJoin identifier", + "required": true, "deprecated": false, "schema": { - "type": "integer", - "default": 30, - "minimum": 0, - "maximum": 100 + "type": "string" }, - "style": "form", - "explode": true - } - ], - "security": [ - { - "bearerAuth": [] + "style": "simple", + "explode": false } ] - }, + } + }, + "/api/auth/password/reset/confirm": { "post": { - "operationId": "user_create", + "operationId": "confirm_password_reset", "tags": [ - "Users" + "Password Reset" ], "responses": { - "201": { - "description": "User created successfully", + "200": { + "description": "Password reset confirmed successfully. All sessions have been revoked.", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/User.UserOutput.jsonld-user.read" + "$ref": "#/components/schemas/PasswordReset.ConfirmPasswordResetOutput.jsonld-token.read" } } }, - "links": {} + "links": { + "Login": { + "operationId": "login", + "description": "Login with the new password" + } + } }, "400": { - "description": "Invalid request - validation failed", + "description": "Invalid request - missing or invalid parameters", "content": { "application/ld+json": { "schema": { @@ -43675,13 +46271,7 @@ "links": {} }, "401": { - "description": "Authentication required" - }, - "403": { - "description": "Insufficient permissions" - }, - "409": { - "description": "User already exists" + "description": "Invalid or expired token/code" }, "422": { "description": "An error occurred", @@ -43703,97 +46293,137 @@ } }, "links": {} + }, + "429": { + "description": "Too many password reset confirmation attempts or maximum verification attempts exceeded" } }, - "summary": "Create user", - "description": "Creates a new user account. Requires users.create permission.", + "summary": "Confirm Password Reset", + "description": "Confirm password reset using the token, code, and new password. All active sessions will be terminated.", "parameters": [], "requestBody": { - "description": "The new User resource", + "description": "The new PasswordReset resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/User.UserInput-user.write" + "$ref": "#/components/schemas/PasswordReset.ConfirmPasswordResetInput-password_reset.write" } } }, "required": true - }, - "security": [ - { - "bearerAuth": [] - } - ] + } } }, - "/api/users/{id}": { - "get": { - "operationId": "user_get", + "/api/auth/password/reset/request": { + "post": { + "operationId": "request_password_reset", "tags": [ - "Users" + "Password Reset" ], "responses": { "200": { - "description": "User retrieved successfully", + "description": "Password reset request processed successfully. If the email exists, a code has been sent.", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/User.UserOutput.jsonld-user.read" + "$ref": "#/components/schemas/PasswordReset.RequestPasswordResetOutput.jsonld-token.read" } } + }, + "links": { + "ConfirmReset": { + "operationId": "confirm_password_reset", + "description": "Confirm the password reset using the token and code" + } } }, - "401": { - "description": "Authentication required" + "400": { + "description": "Invalid request - missing or invalid email", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} }, - "403": { - "description": "Insufficient permissions" + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} }, - "404": { - "description": "User not found" + "429": { + "description": "Too many password reset requests" } }, - "summary": "Get user", - "description": "Retrieve a user by ID. Requires users.read permission.", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "User identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - } - ], - "security": [ - { - "bearerAuth": [] - } - ] - }, - "put": { - "operationId": "user_replace", + "summary": "Request Password Reset", + "description": "Request a password reset by email. An OTP code will be sent to the user's email if the account exists.", + "parameters": [], + "requestBody": { + "description": "The new PasswordReset resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/PasswordReset.RequestPasswordResetInput-password_reset.write" + } + } + }, + "required": true + } + } + }, + "/api/auth/password/reset/resend": { + "post": { + "operationId": "resend_password_reset", "tags": [ - "Users" + "Password Reset" ], "responses": { "200": { - "description": "User replaced successfully", + "description": "Password reset code resent successfully.", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/User.UserOutput.jsonld-user.read" + "$ref": "#/components/schemas/PasswordReset.RequestPasswordResetOutput.jsonld-token.read" } } }, - "links": {} + "links": { + "ConfirmReset": { + "operationId": "confirm_password_reset", + "description": "Confirm the password reset using the new token and code" + } + } }, "400": { - "description": "Invalid request - validation failed", + "description": "Invalid request - missing or invalid token", "content": { "application/ld+json": { "schema": { @@ -43813,14 +46443,8 @@ }, "links": {} }, - "401": { - "description": "Authentication required" - }, - "403": { - "description": "Insufficient permissions" - }, "404": { - "description": "User not found" + "description": "Reset challenge not found or no longer valid" }, "422": { "description": "An error occurred", @@ -43842,101 +46466,47 @@ } }, "links": {} + }, + "429": { + "description": "Resend cooldown not yet elapsed" } }, - "summary": "Replace user", - "description": "Replace a user by ID. Requires users.update permission.", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "User identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - } - ], + "summary": "Resend Password Reset Code", + "description": "Resend the password reset OTP code using the existing challenge token.", + "parameters": [], "requestBody": { - "description": "The updated User resource", + "description": "The new PasswordReset resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/User.UserInput-user.write" + "$ref": "#/components/schemas/PasswordReset.ResendPasswordResetInput-password_reset.write" } } }, "required": true - }, - "security": [ - { - "bearerAuth": [] - } - ] - }, - "delete": { - "operationId": "user_delete", - "tags": [ - "Users" - ], - "responses": { - "204": { - "description": "User deleted successfully" - }, - "401": { - "description": "Authentication required" - }, - "403": { - "description": "Insufficient permissions" - }, - "404": { - "description": "User not found" - } - }, - "summary": "Delete user", - "description": "Delete a user by ID. Requires users.delete permission.", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "User identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - } - ], - "security": [ - { - "bearerAuth": [] - } - ] - }, - "patch": { - "operationId": "user_update", + } + } + }, + "/api/auth/password/setup": { + "post": { + "operationId": "request_initial_password", "tags": [ - "Users" + "PasswordSetup" ], "responses": { "200": { - "description": "User updated successfully", + "description": "PasswordSetup resource created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/User.UserOutput.jsonld-user.read" + "$ref": "#/components/schemas/PasswordSetup.RequestPasswordChangeOutput.jsonld-token.read" } } }, "links": {} }, "400": { - "description": "Invalid request - validation failed", + "description": "Invalid input", "content": { "application/ld+json": { "schema": { @@ -43956,14 +46526,26 @@ }, "links": {} }, - "401": { - "description": "Authentication required" - }, "403": { - "description": "Insufficient permissions" - }, - "404": { - "description": "User not found" + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} }, "422": { "description": "An error occurred", @@ -43987,53 +46569,24 @@ "links": {} } }, - "summary": "Update user (partial)", - "description": "Partially update a user by ID. Requires users.update permission.", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "User identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - } - ], - "requestBody": { - "description": "The updated User resource", - "content": { - "application/merge-patch+json": { - "schema": { - "$ref": "#/components/schemas/User.UserInput-user.write.jsonMergePatch" - } - } - }, - "required": true - }, - "security": [ - { - "bearerAuth": [] - } - ] + "summary": "Creates a PasswordSetup resource.", + "description": "Creates a PasswordSetup resource.", + "parameters": [] } }, - "/api/users/{id}/activate": { + "/api/auth/password/setup/confirm": { "post": { - "operationId": "user_activate", + "operationId": "confirm_initial_password", "tags": [ - "Users" + "PasswordSetup" ], "responses": { "200": { - "description": "User activated successfully", + "description": "PasswordSetup resource created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/User.UserOutput.jsonld-user.read" + "$ref": "#/components/schemas/PasswordSetup.ConfirmPasswordChangeOutput.jsonld-token.read" } } }, @@ -44060,14 +46613,26 @@ }, "links": {} }, - "401": { - "description": "Authentication required" - }, "403": { - "description": "Insufficient permissions" - }, - "404": { - "description": "User not found" + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} }, "422": { "description": "An error occurred", @@ -44091,82 +46656,107 @@ "links": {} } }, - "summary": "Activate user", - "description": "Activate a user account. Requires users.update permission.", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "User identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - } - ], - "security": [ - { - "bearerAuth": [] - } - ] + "summary": "Creates a PasswordSetup resource.", + "description": "Creates a PasswordSetup resource.", + "parameters": [], + "requestBody": { + "description": "The new PasswordSetup resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/PasswordSetup.ConfirmPasswordChangeInput-password_change.write" + } + } + }, + "required": true + } } }, - "/api/users/{id}/avatar": { + "/api/plans": { "get": { - "operationId": "user_get_avatar", + "operationId": "listPlans", "tags": [ - "Users" + "Plan" ], "responses": { "200": { - "description": "Avatar image (WebP)" + "description": "Plans retrieved", + "content": { + "application/ld+json": { + "schema": { + "type": "object", + "description": "Plan.PlanOutput.jsonld-Plan.read collection.", + "allOf": [ + { + "$ref": "#/components/schemas/HydraCollectionBaseSchemaNoPagination" + }, + { + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Plan.PlanOutput.jsonld-Plan.read" + } + } + } + } + ] + } + } + } }, - "204": { - "description": "User resource" + "401": { + "description": "Authentication required" }, - "404": { - "description": "User or avatar not found" - } - }, - "summary": "Get user avatar", - "description": "Streams the avatar as WebP. Optional ?size=N query parameter resolved to the nearest variant (256, 128, 64 or 32 px). Public endpoint.", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "User identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } }, - "style": "simple", - "explode": false + "links": {} } - ] + }, + "summary": "List plans", + "description": "Lists subscription plans. Administrators see every plan; regular users see selectable plans only.", + "parameters": [] }, "post": { - "operationId": "user_upload_avatar", + "operationId": "createPlan", "tags": [ - "Users" + "Plan" ], "responses": { - "200": { - "description": "Avatar uploaded — user output with updated avatarUrl returned", + "201": { + "description": "Plan created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/User.UserOutput.jsonld-user.read" + "$ref": "#/components/schemas/Plan.PlanOutput.jsonld-Plan.read" } } }, "links": {} }, "400": { - "description": "Invalid input", + "description": "Invalid request - validation failed", "content": { "application/ld+json": { "schema": { @@ -44186,17 +46776,14 @@ }, "links": {} }, - "401": { - "description": "Authentication required" - }, "403": { - "description": "Insufficient permissions" + "description": "Administrator privileges required" }, - "404": { - "description": "User not found" + "409": { + "description": "Plan key already in use" }, "422": { - "description": "Invalid file — missing, too large, or unsupported MIME type", + "description": "An error occurred", "content": { "application/ld+json": { "schema": { @@ -44217,74 +46804,71 @@ "links": {} } }, - "summary": "Upload user avatar", - "description": "Uploads an avatar image for a user. Accepted formats: JPEG, PNG, WebP, GIF (max 5 MB). The image is resized to four WebP variants (256, 128, 64, 32 px) with cover crop. Requires users.update permission.", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "User identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - } - ], + "summary": "Create plan", + "description": "Creates a subscription plan. Requires platform administrator privileges.", + "parameters": [], "requestBody": { - "description": "Avatar image file (JPEG, PNG, WebP or GIF, max 5 MB)", + "description": "The new Plan resource", "content": { - "multipart/form-data": { + "application/ld+json": { "schema": { - "type": "object", - "required": [ - "avatar" - ], - "properties": { - "avatar": { - "type": "string", - "format": "binary", - "description": "Image file (JPEG, PNG, WebP or GIF), max 5 MB" - } - } + "$ref": "#/components/schemas/Plan.CreatePlanInput-Plan.write" } } }, "required": true - }, - "security": [ - { - "bearerAuth": [] - } - ] + } } }, - "/api/users/{id}/avatar/{size}.webp": { + "/api/plans/{id}": { "get": { - "operationId": "user_get_avatar_sized", + "operationId": "getPlan", "tags": [ - "Users" + "Plan" ], "responses": { "200": { - "description": "Avatar image (WebP)" + "description": "Plan retrieved", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Plan.PlanOutput.jsonld-Plan.read" + } + } + } }, - "204": { - "description": "User resource" + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} }, "404": { - "description": "User or avatar not found" + "description": "Plan not found" } }, - "summary": "Get user avatar (sized)", - "description": "Streams the avatar WebP variant closest to the requested size (256, 128, 64 or 32 px). Public endpoint.", + "summary": "Get plan", + "description": "Returns a single subscription plan by identifier.", "parameters": [ { "name": "id", "in": "path", - "description": "User identifier", + "description": "Plan identifier", "required": true, "deprecated": false, "schema": { @@ -44292,11 +46876,35 @@ }, "style": "simple", "explode": false + } + ] + }, + "delete": { + "operationId": "deletePlan", + "tags": [ + "Plan" + ], + "responses": { + "204": { + "description": "Plan deleted" + }, + "403": { + "description": "Administrator privileges required" }, + "404": { + "description": "Plan not found" + }, + "409": { + "description": "The default plan cannot be deleted" + } + }, + "summary": "Delete plan", + "description": "Deletes a subscription plan. The default plan cannot be deleted. Requires platform administrator privileges.", + "parameters": [ { - "name": "size", + "name": "id", "in": "path", - "description": "User identifier", + "description": "Plan identifier", "required": true, "deprecated": false, "schema": { @@ -44306,28 +46914,26 @@ "explode": false } ] - } - }, - "/api/users/{id}/deactivate": { - "post": { - "operationId": "user_deactivate", + }, + "patch": { + "operationId": "updatePlan", "tags": [ - "Users" + "Plan" ], "responses": { "200": { - "description": "User deactivated successfully", + "description": "Plan updated", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/User.UserOutput.jsonld-user.read" + "$ref": "#/components/schemas/Plan.PlanOutput.jsonld-Plan.read" } } }, "links": {} }, "400": { - "description": "Invalid input", + "description": "Invalid request - validation failed", "content": { "application/ld+json": { "schema": { @@ -44347,14 +46953,11 @@ }, "links": {} }, - "401": { - "description": "Authentication required" - }, "403": { - "description": "Insufficient permissions" + "description": "Administrator privileges required" }, "404": { - "description": "User not found" + "description": "Plan not found" }, "422": { "description": "An error occurred", @@ -44378,13 +46981,13 @@ "links": {} } }, - "summary": "Deactivate user", - "description": "Deactivate a user account. Requires users.update permission.", + "summary": "Update plan", + "description": "Updates a subscription plan. Requires platform administrator privileges.", "parameters": [ { "name": "id", "in": "path", - "description": "User identifier", + "description": "Plan identifier", "required": true, "deprecated": false, "schema": { @@ -44394,33 +46997,58 @@ "explode": false } ], - "security": [ - { - "bearerAuth": [] - } - ] + "requestBody": { + "description": "The updated Plan resource", + "content": { + "application/merge-patch+json": { + "schema": { + "$ref": "#/components/schemas/Plan.UpdatePlanInput-Plan.write.jsonMergePatch" + } + } + }, + "required": true + } } }, - "/api/users/{id}/verify-email": { - "post": { - "operationId": "user_verify_email", + "/api/presence": { + "get": { + "operationId": "messaging_get_presence", "tags": [ - "Users" + "Presence" ], "responses": { "200": { - "description": "User email verified successfully", + "description": "Presence collection", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/User.UserOutput.jsonld-user.read" + "type": "object", + "description": "Presence.PresenceOutput.jsonld collection.", + "allOf": [ + { + "$ref": "#/components/schemas/HydraCollectionBaseSchemaNoPagination" + }, + { + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Presence.PresenceOutput.jsonld" + } + } + } + } + ] } } - }, - "links": {} + } }, - "400": { - "description": "Invalid input", + "403": { + "description": "Forbidden", "content": { "application/ld+json": { "schema": { @@ -44439,100 +47067,58 @@ } }, "links": {} - }, - "401": { - "description": "Authentication required" - }, - "403": { - "description": "Insufficient permissions" - }, - "404": { - "description": "User not found" - }, - "422": { - "description": "An error occurred", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/ConstraintViolation" - } - } - }, - "links": {} } }, - "summary": "Verify user email", - "description": "Mark a user email as verified. Requires users.update permission.", + "summary": "Retrieves the collection of Presence resources.", + "description": "Retrieves the collection of Presence resources.", "parameters": [ { - "name": "id", - "in": "path", - "description": "User identifier", + "name": "organization", + "in": "query", + "description": "Organization IRI.", "required": true, "deprecated": false, "schema": { "type": "string" }, - "style": "simple", - "explode": false - } - ], - "security": [ + "style": "form", + "explode": true + }, { - "bearerAuth": [] + "name": "memberIds", + "in": "query", + "description": "Comma-separated organization member ids to check presence for (max 100). Required — there is no \"list all online members\" mode.", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "form", + "explode": true } ] } }, - "/api/organizations/{organizationId}/webhooks": { - "get": { - "operationId": "listWebhookSubscriptions", + "/api/presence/ping": { + "post": { + "operationId": "messaging_ping_presence", "tags": [ - "Webhooks" + "Presence" ], "responses": { "200": { - "description": "WebhookSubscription collection", + "description": "Presence resource created", "content": { "application/ld+json": { "schema": { - "type": "object", - "description": "WebhookSubscription.WebhookSubscriptionOutput.jsonld-Webhook.read collection.", - "allOf": [ - { - "$ref": "#/components/schemas/HydraCollectionBaseSchema" - }, - { - "type": "object", - "required": [ - "member" - ], - "properties": { - "member": { - "type": "array", - "items": { - "$ref": "#/components/schemas/WebhookSubscription.WebhookSubscriptionOutput.jsonld-Webhook.read" - } - } - } - } - ] + "$ref": "#/components/schemas/Presence.PingPresenceOutput.jsonld" } } - } + }, + "links": {} }, - "403": { - "description": "Forbidden", + "400": { + "description": "Invalid input", "content": { "application/ld+json": { "schema": { @@ -44551,72 +47137,9 @@ } }, "links": {} - } - }, - "summary": "List webhook subscriptions", - "description": "Lists an organization's webhook subscriptions. Requires organization.webhooks.read.", - "parameters": [ - { - "name": "organizationId", - "in": "path", - "description": "WebhookSubscription identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - }, - { - "name": "page", - "in": "query", - "description": "The collection page number", - "required": false, - "deprecated": false, - "schema": { - "type": "integer", - "default": 1 - }, - "style": "form", - "explode": true - }, - { - "name": "itemsPerPage", - "in": "query", - "description": "The number of items per page", - "required": false, - "deprecated": false, - "schema": { - "type": "integer", - "default": 30, - "minimum": 0, - "maximum": 100 - }, - "style": "form", - "explode": true - } - ] - }, - "post": { - "operationId": "createWebhookSubscription", - "tags": [ - "Webhooks" - ], - "responses": { - "201": { - "description": "Subscription created, secret revealed once", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/WebhookSubscription.WebhookSecretOutput.jsonld-Webhook.read" - } - } - }, - "links": {} }, - "400": { - "description": "Invalid input", + "403": { + "description": "Forbidden", "content": { "application/ld+json": { "schema": { @@ -44636,11 +47159,8 @@ }, "links": {} }, - "403": { - "description": "Insufficient permissions" - }, "422": { - "description": "Invalid URL, unknown event type, or subscription cap reached", + "description": "An error occurred", "content": { "application/ld+json": { "schema": { @@ -44661,28 +47181,15 @@ "links": {} } }, - "summary": "Create webhook subscription", - "description": "Creates an outbound webhook subscription. Requires organization.webhooks.manage. The response includes the PLAINTEXT signing secret exactly once.", - "parameters": [ - { - "name": "organizationId", - "in": "path", - "description": "WebhookSubscription identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - } - ], + "summary": "Creates a Presence resource.", + "description": "Creates a Presence resource.", + "parameters": [], "requestBody": { - "description": "The new WebhookSubscription resource", + "description": "The new Presence resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/WebhookSubscription.CreateWebhookSubscriptionInput-Webhook.write" + "$ref": "#/components/schemas/Presence.PingPresenceInput" } } }, @@ -44690,19 +47197,19 @@ } } }, - "/api/organizations/{organizationId}/webhooks/{webhookId}": { + "/api/presence/subscription": { "get": { - "operationId": "getWebhookSubscription", + "operationId": "messaging_presence_subscription", "tags": [ - "Webhooks" + "Presence" ], "responses": { "200": { - "description": "Subscription retrieved", + "description": "Presence resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/WebhookSubscription.WebhookSubscriptionOutput.jsonld-Webhook.read" + "$ref": "#/components/schemas/Presence.PresenceSubscriptionOutput.jsonld-presence.read" } } } @@ -44729,95 +47236,121 @@ "links": {} }, "404": { - "description": "Organization or subscription not found" + "description": "Not found", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} } }, - "summary": "Get webhook subscription", - "description": "Returns a single webhook subscription. Requires organization.webhooks.read.", + "summary": "Retrieves a Presence resource.", + "description": "Retrieves a Presence resource.", "parameters": [ { - "name": "organizationId", - "in": "path", - "description": "WebhookSubscription identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - }, - { - "name": "webhookId", - "in": "path", - "description": "WebhookSubscription identifier", + "name": "organization", + "in": "query", + "description": "Presence organization", "required": true, "deprecated": false, "schema": { "type": "string" }, - "style": "simple", - "explode": false + "style": "form", + "explode": true } ] - }, - "delete": { - "operationId": "deleteWebhookSubscription", + } + }, + "/api/me/presence-preference": { + "get": { + "operationId": "user_get_presence_preference", "tags": [ - "Webhooks" + "PresencePreference" ], "responses": { - "204": { - "description": "Subscription deleted" + "200": { + "description": "PresencePreference resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/PresencePreference.PresencePreferenceOutput.jsonld-presence-preference.read" + } + } + } }, "403": { - "description": "Insufficient permissions" - }, - "404": { - "description": "Organization or subscription not found" - } - }, - "summary": "Delete webhook subscription", - "description": "Permanently deletes a webhook subscription and its delivery log. Requires organization.webhooks.manage.", - "parameters": [ - { - "name": "organizationId", - "in": "path", - "description": "WebhookSubscription identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } }, - "style": "simple", - "explode": false + "links": {} }, - { - "name": "webhookId", - "in": "path", - "description": "WebhookSubscription identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" + "404": { + "description": "Not found", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } }, - "style": "simple", - "explode": false + "links": {} } - ] + }, + "summary": "Retrieves a PresencePreference resource.", + "description": "Retrieves a PresencePreference resource.", + "parameters": [] }, "patch": { - "operationId": "updateWebhookSubscription", + "operationId": "user_update_presence_preference", "tags": [ - "Webhooks" + "PresencePreference" ], "responses": { "200": { - "description": "WebhookSubscription resource updated", + "description": "PresencePreference resource updated", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/WebhookSubscription.WebhookSubscriptionOutput.jsonld-Webhook.read" + "$ref": "#/components/schemas/PresencePreference.PresencePreferenceOutput.jsonld-presence-preference.read" } } }, @@ -44908,40 +47441,15 @@ "links": {} } }, - "summary": "Update webhook subscription", - "description": "Partially updates a webhook subscription (url, eventTypes, isActive, description). Requires organization.webhooks.manage.", - "parameters": [ - { - "name": "organizationId", - "in": "path", - "description": "WebhookSubscription identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - }, - { - "name": "webhookId", - "in": "path", - "description": "WebhookSubscription identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - } - ], + "summary": "Updates the PresencePreference resource.", + "description": "Updates the PresencePreference resource.", + "parameters": [], "requestBody": { - "description": "The updated WebhookSubscription resource", + "description": "The updated PresencePreference resource", "content": { "application/merge-patch+json": { "schema": { - "$ref": "#/components/schemas/WebhookSubscription.UpdateWebhookSubscriptionInput-Webhook.write.jsonMergePatch" + "$ref": "#/components/schemas/PresencePreference.UpdatePresencePreferenceInput-presence-preference.write.jsonMergePatch" } } }, @@ -44949,20 +47457,85 @@ } } }, - "/api/organizations/{organizationId}/webhooks/{webhookId}/deliveries": { + "/api/me/presence-preference/subscription": { "get": { - "operationId": "listWebhookDeliveries", + "operationId": "user_presence_preference_subscription", "tags": [ - "Webhooks" + "PresencePreference" ], "responses": { "200": { - "description": "WebhookDelivery collection", + "description": "PresencePreference resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/PresencePreference.PresencePreferenceSubscriptionOutput.jsonld-presence-preference.read" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "404": { + "description": "Not found", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + } + }, + "summary": "Retrieves a PresencePreference resource.", + "description": "Retrieves a PresencePreference resource.", + "parameters": [] + } + }, + "/api/organizations/{organizationId}/procurement/orders": { + "get": { + "operationId": "procurement_orders", + "tags": [ + "Procurement" + ], + "responses": { + "200": { + "description": "Procurement collection", "content": { "application/ld+json": { "schema": { "type": "object", - "description": "WebhookDelivery.WebhookDeliveryOutput.jsonld-Webhook.read collection.", + "description": "Procurement.PurchaseOrderOutput.jsonld-procurement.read collection.", "allOf": [ { "$ref": "#/components/schemas/HydraCollectionBaseSchema" @@ -44976,7 +47549,7 @@ "member": { "type": "array", "items": { - "$ref": "#/components/schemas/WebhookDelivery.WebhookDeliveryOutput.jsonld-Webhook.read" + "$ref": "#/components/schemas/Procurement.PurchaseOrderOutput.jsonld-procurement.read" } } } @@ -45008,25 +47581,13 @@ "links": {} } }, - "summary": "List webhook deliveries", - "description": "Lists a subscription's delivery log. Requires organization.webhooks.read.", + "summary": "List purchase orders", + "description": "List purchase orders", "parameters": [ { "name": "organizationId", "in": "path", - "description": "WebhookDelivery identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - }, - { - "name": "webhookId", - "in": "path", - "description": "WebhookDelivery identifier", + "description": "Procurement identifier", "required": true, "deprecated": false, "schema": { @@ -45066,31 +47627,49 @@ { "name": "status", "in": "query", - "description": "Delivery status filter (pending|delivered|failed).", + "description": "Procurement status", "required": false, "deprecated": false, "schema": { - "type": "string" + "type": "string", + "enum": [ + "draft", + "ordered", + "partial_received", + "received", + "cancelled" + ] + }, + "style": "form", + "explode": true + }, + { + "name": "supplierId", + "in": "query", + "description": "Procurement supplierId", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "uuid" }, "style": "form", "explode": true } ] - } - }, - "/api/organizations/{organizationId}/webhooks/{webhookId}/deliveries/{deliveryId}/redeliver": { + }, "post": { - "operationId": "redeliverWebhook", + "operationId": "procurement_create_order", "tags": [ - "Webhooks" + "Procurement" ], "responses": { - "202": { - "description": "Redelivery enqueued", + "201": { + "description": "Procurement resource created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/WebhookDelivery.WebhookPingOutput.jsonld-Webhook.read" + "$ref": "#/components/schemas/Procurement.PurchaseOrderOutput.jsonld-procurement.read" } } }, @@ -45138,9 +47717,6 @@ }, "links": {} }, - "404": { - "description": "Organization, subscription, or delivery not found" - }, "422": { "description": "An error occurred", "content": { @@ -45163,13 +47739,13 @@ "links": {} } }, - "summary": "Redeliver a webhook delivery", - "description": "Re-enqueues a delivery (any status, including terminally failed) for another attempt. Requires organization.webhooks.manage.", + "summary": "Create a purchase draft", + "description": "Create a purchase draft", "parameters": [ { "name": "organizationId", "in": "path", - "description": "WebhookDelivery identifier", + "description": "Procurement identifier", "required": true, "deprecated": false, "schema": { @@ -45177,11 +47753,88 @@ }, "style": "simple", "explode": false - }, - { - "name": "webhookId", + } + ], + "requestBody": { + "description": "The new Procurement resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Procurement.CreatePurchaseOrderInput-procurement.write" + } + } + }, + "required": true + } + } + }, + "/api/organizations/{organizationId}/procurement/orders/{id}": { + "get": { + "operationId": "procurement_order", + "tags": [ + "Procurement" + ], + "responses": { + "200": { + "description": "Procurement resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Procurement.PurchaseOrderOutput.jsonld-procurement.read" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "404": { + "description": "Not found", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + } + }, + "summary": "Read a purchase order", + "description": "Read a purchase order", + "parameters": [ + { + "name": "organizationId", "in": "path", - "description": "WebhookDelivery identifier", + "description": "Procurement identifier", "required": true, "deprecated": false, "schema": { @@ -45191,9 +47844,9 @@ "explode": false }, { - "name": "deliveryId", + "name": "id", "in": "path", - "description": "WebhookDelivery identifier", + "description": "Procurement identifier", "required": true, "deprecated": false, "schema": { @@ -45203,21 +47856,19 @@ "explode": false } ] - } - }, - "/api/organizations/{organizationId}/webhooks/{webhookId}/ping": { - "post": { - "operationId": "pingWebhookSubscription", + }, + "patch": { + "operationId": "procurement_change_order", "tags": [ - "Webhooks" + "Procurement" ], "responses": { - "202": { - "description": "Test delivery enqueued", + "200": { + "description": "Procurement resource updated", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/WebhookSubscription.WebhookPingOutput.jsonld-Webhook.read" + "$ref": "#/components/schemas/Procurement.PurchaseOrderOutput.jsonld-procurement.read" } } }, @@ -45265,6 +47916,27 @@ }, "links": {} }, + "404": { + "description": "Not found", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, "422": { "description": "An error occurred", "content": { @@ -45287,13 +47959,13 @@ "links": {} } }, - "summary": "Send a test delivery", - "description": "Enqueues a synthetic `webhook.ping` test delivery to this subscription. Requires organization.webhooks.manage.", + "summary": "Edit a purchase draft", + "description": "Edit a purchase draft", "parameters": [ { "name": "organizationId", "in": "path", - "description": "WebhookSubscription identifier", + "description": "Procurement identifier", "required": true, "deprecated": false, "schema": { @@ -45303,9 +47975,9 @@ "explode": false }, { - "name": "webhookId", + "name": "id", "in": "path", - "description": "WebhookSubscription identifier", + "description": "Procurement identifier", "required": true, "deprecated": false, "schema": { @@ -45314,22 +47986,33 @@ "style": "simple", "explode": false } - ] + ], + "requestBody": { + "description": "The updated Procurement resource", + "content": { + "application/merge-patch+json": { + "schema": { + "$ref": "#/components/schemas/Procurement.ChangePurchaseOrderInput-procurement.write.jsonMergePatch" + } + } + }, + "required": true + } } }, - "/api/organizations/{organizationId}/webhooks/{webhookId}/rotate-secret": { + "/api/organizations/{organizationId}/procurement/orders/{id}/cancel-remaining": { "post": { - "operationId": "rotateWebhookSecret", + "operationId": "procurement_cancel_remaining", "tags": [ - "Webhooks" + "Procurement" ], "responses": { "200": { - "description": "WebhookSubscription resource created", + "description": "Procurement resource created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/WebhookSubscription.WebhookSecretOutput.jsonld-Webhook.read" + "$ref": "#/components/schemas/Procurement.PurchaseOrderOutput.jsonld-procurement.read" } } }, @@ -45399,13 +48082,13 @@ "links": {} } }, - "summary": "Rotate webhook signing secret", - "description": "Generates a new signing secret, invalidating the previous one. Requires organization.webhooks.manage. The response includes the new PLAINTEXT secret exactly once.", + "summary": "Cancel only quantities not yet received", + "description": "Cancel only quantities not yet received", "parameters": [ { "name": "organizationId", "in": "path", - "description": "WebhookSubscription identifier", + "description": "Procurement identifier", "required": true, "deprecated": false, "schema": { @@ -45415,9 +48098,9 @@ "explode": false }, { - "name": "webhookId", + "name": "id", "in": "path", - "description": "WebhookSubscription identifier", + "description": "Procurement identifier", "required": true, "deprecated": false, "schema": { @@ -45429,45 +48112,26 @@ ] } }, - "/api/webhooks/event-types": { - "get": { - "operationId": "listWebhookEventTypes", + "/api/organizations/{organizationId}/procurement/orders/{id}/order": { + "post": { + "operationId": "procurement_place_order", "tags": [ - "Webhooks" + "Procurement" ], "responses": { "200": { - "description": "WebhookEventType collection", + "description": "Procurement resource created", "content": { "application/ld+json": { "schema": { - "type": "object", - "description": "WebhookEventType.WebhookEventTypeOutput.jsonld-Webhook.read collection.", - "allOf": [ - { - "$ref": "#/components/schemas/HydraCollectionBaseSchemaNoPagination" - }, - { - "type": "object", - "required": [ - "member" - ], - "properties": { - "member": { - "type": "array", - "items": { - "$ref": "#/components/schemas/WebhookEventType.WebhookEventTypeOutput.jsonld-Webhook.read" - } - } - } - } - ] + "$ref": "#/components/schemas/Procurement.PurchaseOrderOutput.jsonld-procurement.read" } } - } + }, + "links": {} }, - "403": { - "description": "Forbidden", + "400": { + "description": "Invalid input", "content": { "application/ld+json": { "schema": { @@ -45486,29 +48150,6 @@ } }, "links": {} - } - }, - "summary": "List webhook event types", - "description": "Returns the curated public event type allowlist a webhook subscription may register for.", - "parameters": [] - } - }, - "/api/organizations/{organizationId}/workload": { - "get": { - "operationId": "workload_get", - "tags": [ - "Workload" - ], - "responses": { - "200": { - "description": "Workload resource", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Workload.WorkloadOutput.jsonld" - } - } - } }, "403": { "description": "Forbidden", @@ -45531,35 +48172,35 @@ }, "links": {} }, - "404": { - "description": "Not found", + "422": { + "description": "An error occurred", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Error.jsonld" + "$ref": "#/components/schemas/ConstraintViolation.jsonld" } }, "application/problem+json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/ConstraintViolation" } }, "application/json": { "schema": { - "$ref": "#/components/schemas/Error" + "$ref": "#/components/schemas/ConstraintViolation" } } }, "links": {} } }, - "summary": "Retrieves a Workload resource.", - "description": "Retrieves a Workload resource.", + "summary": "Place a purchase order", + "description": "Place a purchase order", "parameters": [ { "name": "organizationId", "in": "path", - "description": "Workload identifier", + "description": "Procurement identifier", "required": true, "deprecated": false, "schema": { @@ -45569,112 +48210,148 @@ "explode": false }, { - "name": "from", - "in": "query", - "description": "Workload from", + "name": "id", + "in": "path", + "description": "Procurement identifier", "required": true, "deprecated": false, "schema": { - "type": "string", - "format": "date" + "type": "string" }, - "style": "form", - "explode": true + "style": "simple", + "explode": false + } + ] + } + }, + "/api/organizations/{organizationId}/procurement/orders/{id}/receipts": { + "get": { + "operationId": "procurement_receipts", + "tags": [ + "Procurement" + ], + "responses": { + "200": { + "description": "Procurement collection", + "content": { + "application/ld+json": { + "schema": { + "type": "object", + "description": "Procurement.ProcurementReceiptOutput.jsonld-procurement.read collection.", + "allOf": [ + { + "$ref": "#/components/schemas/HydraCollectionBaseSchema" + }, + { + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Procurement.ProcurementReceiptOutput.jsonld-procurement.read" + } + } + } + } + ] + } + } + } }, - { - "name": "to", - "in": "query", - "description": "Workload to", - "required": true, - "deprecated": false, - "schema": { - "type": "string", - "format": "date" + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } }, - "style": "form", - "explode": true - }, + "links": {} + } + }, + "summary": "List retained physical receipts", + "description": "List retained physical receipts", + "parameters": [ { - "name": "member", - "in": "query", - "description": "Workload member", - "required": false, + "name": "organizationId", + "in": "path", + "description": "Procurement identifier", + "required": true, "deprecated": false, "schema": { "type": "string" }, - "style": "form", - "explode": true + "style": "simple", + "explode": false }, { - "name": "team", - "in": "query", - "description": "Workload team", - "required": false, + "name": "id", + "in": "path", + "description": "Procurement identifier", + "required": true, "deprecated": false, "schema": { "type": "string" }, - "style": "form", - "explode": true - }, - { - "name": "overloaded", - "in": "query", - "description": "Workload overloaded", - "required": false, - "deprecated": false, - "schema": { - "type": "boolean" - }, - "style": "form", - "explode": true + "style": "simple", + "explode": false }, { "name": "page", "in": "query", - "description": "One-based member page. Pagination never limits workload contributions.", + "description": "The collection page number", "required": false, "deprecated": false, "schema": { "type": "integer", - "minimum": 1, "default": 1 }, "style": "form", "explode": true }, { - "name": "pageSize", + "name": "itemsPerPage", "in": "query", - "description": "Members per page, after member, team and overload filters.", + "description": "The number of items per page", "required": false, "deprecated": false, "schema": { "type": "integer", - "minimum": 1, - "maximum": 100, - "default": 10 + "default": 30, + "minimum": 0, + "maximum": 100 }, "style": "form", "explode": true } ] - } - }, - "/api/organizations/{organizationId}/workload/assessments": { + }, "post": { - "operationId": "workload_assess", + "operationId": "procurement_receive", "tags": [ - "Workload" + "Procurement" ], "responses": { - "200": { - "description": "Workload resource created", + "201": { + "description": "Procurement resource created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Workload.WorkloadAssessmentOutput.jsonld" + "$ref": "#/components/schemas/Procurement.ProcurementReceiptOutput.jsonld-procurement.read" } } }, @@ -45744,13 +48421,25 @@ "links": {} } }, - "summary": "Creates a Workload resource.", - "description": "Creates a Workload resource.", + "summary": "Atomically record physical and stock receipt", + "description": "Atomically record physical and stock receipt", "parameters": [ { "name": "organizationId", "in": "path", - "description": "Workload identifier", + "description": "Procurement identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "id", + "in": "path", + "description": "Procurement identifier", "required": true, "deprecated": false, "schema": { @@ -45761,11 +48450,11 @@ } ], "requestBody": { - "description": "The new Workload resource", + "description": "The new Procurement resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/Workload.WorkloadAssessmentInput" + "$ref": "#/components/schemas/Procurement.ReceivePurchaseOrderInput-procurement.write" } } }, @@ -45773,19 +48462,19 @@ } } }, - "/api/organizations/{organizationId}/workload/members/{memberId}/capacity": { + "/api/organizations/{organizationId}/procurement/receipts/{id}": { "get": { - "operationId": "workload_capacity_get", + "operationId": "procurement_receipt", "tags": [ - "WorkloadCapacity" + "Procurement" ], "responses": { "200": { - "description": "WorkloadCapacity resource", + "description": "Procurement resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/WorkloadCapacity.CapacityOutput.jsonld" + "$ref": "#/components/schemas/Procurement.ProcurementReceiptOutput.jsonld-procurement.read" } } } @@ -45833,13 +48522,13 @@ "links": {} } }, - "summary": "Retrieves a WorkloadCapacity resource.", - "description": "Retrieves a WorkloadCapacity resource.", + "summary": "Read a physical receipt", + "description": "Read a physical receipt", "parameters": [ { "name": "organizationId", "in": "path", - "description": "WorkloadCapacity identifier", + "description": "Procurement identifier", "required": true, "deprecated": false, "schema": { @@ -45849,9 +48538,9 @@ "explode": false }, { - "name": "memberId", + "name": "id", "in": "path", - "description": "WorkloadCapacity identifier", + "description": "Procurement identifier", "required": true, "deprecated": false, "schema": { @@ -45861,19 +48550,21 @@ "explode": false } ] - }, + } + }, + "/api/organizations/{organizationId}/procurement/receipts/{id}/individualize": { "post": { - "operationId": "workload_capacity_save", + "operationId": "procurement_individualize", "tags": [ - "WorkloadCapacity" + "Procurement" ], "responses": { - "201": { - "description": "WorkloadCapacity resource created", + "200": { + "description": "Procurement resource created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/WorkloadCapacity.CapacityChangeOutput.jsonld" + "$ref": "#/components/schemas/Procurement.ProcurementReceiptOutput.jsonld-procurement.read" } } }, @@ -45943,13 +48634,13 @@ "links": {} } }, - "summary": "Creates a WorkloadCapacity resource.", - "description": "Creates a WorkloadCapacity resource.", + "summary": "Create reserve equipment for received units", + "description": "Create reserve equipment for received units", "parameters": [ { "name": "organizationId", "in": "path", - "description": "WorkloadCapacity identifier", + "description": "Procurement identifier", "required": true, "deprecated": false, "schema": { @@ -45959,9 +48650,9 @@ "explode": false }, { - "name": "memberId", + "name": "id", "in": "path", - "description": "WorkloadCapacity identifier", + "description": "Procurement identifier", "required": true, "deprecated": false, "schema": { @@ -45972,11 +48663,11 @@ } ], "requestBody": { - "description": "The new WorkloadCapacity resource", + "description": "The new Procurement resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/WorkloadCapacity.CapacityWeekInput" + "$ref": "#/components/schemas/Procurement.IndividualizeReceiptInput-procurement.write" } } }, @@ -45984,19 +48675,39 @@ } } }, - "/api/organizations/{organizationId}/workload/members/{memberId}/exceptions": { + "/api/organizations/{organizationId}/procurement/receipts/{id}/returns": { "get": { - "operationId": "workload_exceptions_get", + "operationId": "procurement_returns", "tags": [ - "WorkloadCapacity" + "Procurement" ], "responses": { "200": { - "description": "WorkloadCapacity resource", + "description": "Procurement collection", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/WorkloadCapacity.CapacityOutput.jsonld" + "type": "object", + "description": "Procurement.ProcurementReturnOutput.jsonld-procurement.read collection.", + "allOf": [ + { + "$ref": "#/components/schemas/HydraCollectionBaseSchema" + }, + { + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Procurement.ProcurementReturnOutput.jsonld-procurement.read" + } + } + } + } + ] } } } @@ -46021,36 +48732,15 @@ } }, "links": {} - }, - "404": { - "description": "Not found", - "content": { - "application/ld+json": { - "schema": { - "$ref": "#/components/schemas/Error.jsonld" - } - }, - "application/problem+json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - }, - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - }, - "links": {} } }, - "summary": "Retrieves a WorkloadCapacity resource.", - "description": "Retrieves a WorkloadCapacity resource.", + "summary": "List retained physical supplier returns", + "description": "List retained physical supplier returns", "parameters": [ { "name": "organizationId", "in": "path", - "description": "WorkloadCapacity identifier", + "description": "Procurement identifier", "required": true, "deprecated": false, "schema": { @@ -46060,9 +48750,9 @@ "explode": false }, { - "name": "memberId", + "name": "id", "in": "path", - "description": "WorkloadCapacity identifier", + "description": "Procurement identifier", "required": true, "deprecated": false, "schema": { @@ -46070,21 +48760,49 @@ }, "style": "simple", "explode": false + }, + { + "name": "page", + "in": "query", + "description": "The collection page number", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 1 + }, + "style": "form", + "explode": true + }, + { + "name": "itemsPerPage", + "in": "query", + "description": "The number of items per page", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 30, + "minimum": 0, + "maximum": 100 + }, + "style": "form", + "explode": true } ] }, "post": { - "operationId": "workload_exception_create", + "operationId": "procurement_return", "tags": [ - "WorkloadCapacity" + "Procurement" ], "responses": { - "201": { - "description": "WorkloadCapacity resource created", + "200": { + "description": "Procurement resource created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/WorkloadCapacity.CapacityChangeOutput.jsonld" + "$ref": "#/components/schemas/Procurement.ProcurementReceiptOutput.jsonld-procurement.read" } } }, @@ -46154,13 +48872,13 @@ "links": {} } }, - "summary": "Creates a WorkloadCapacity resource.", - "description": "Creates a WorkloadCapacity resource.", + "summary": "Record a motivated supply return", + "description": "Record a motivated supply return", "parameters": [ { "name": "organizationId", "in": "path", - "description": "WorkloadCapacity identifier", + "description": "Procurement identifier", "required": true, "deprecated": false, "schema": { @@ -46170,9 +48888,9 @@ "explode": false }, { - "name": "memberId", + "name": "id", "in": "path", - "description": "WorkloadCapacity identifier", + "description": "Procurement identifier", "required": true, "deprecated": false, "schema": { @@ -46183,11 +48901,11 @@ } ], "requestBody": { - "description": "The new WorkloadCapacity resource", + "description": "The new Procurement resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/WorkloadCapacity.CapacityExceptionInput" + "$ref": "#/components/schemas/Procurement.ReturnProcurementReceiptInput-procurement.write" } } }, @@ -46195,15 +48913,22 @@ } } }, - "/api/organizations/{organizationId}/workload/members/{memberId}/exceptions/{exceptionId}": { - "delete": { - "operationId": "workload_exception_cancel", + "/api/organizations/{organizationId}/procurement/returns/{id}": { + "get": { + "operationId": "procurement_return_detail", "tags": [ - "WorkloadCapacity" + "Procurement" ], "responses": { - "204": { - "description": "WorkloadCapacity resource deleted" + "200": { + "description": "Procurement resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Procurement.ProcurementReturnOutput.jsonld-procurement.read" + } + } + } }, "403": { "description": "Forbidden", @@ -46248,25 +48973,13 @@ "links": {} } }, - "summary": "Removes the WorkloadCapacity resource.", - "description": "Removes the WorkloadCapacity resource.", + "summary": "Read a retained physical return", + "description": "Read a retained physical return", "parameters": [ { "name": "organizationId", "in": "path", - "description": "WorkloadCapacity identifier", - "required": true, - "deprecated": false, - "schema": { - "type": "string" - }, - "style": "simple", - "explode": false - }, - { - "name": "memberId", - "in": "path", - "description": "WorkloadCapacity identifier", + "description": "Procurement identifier", "required": true, "deprecated": false, "schema": { @@ -46276,9 +48989,9 @@ "explode": false }, { - "name": "exceptionId", + "name": "id", "in": "path", - "description": "WorkloadCapacity identifier", + "description": "Procurement identifier", "required": true, "deprecated": false, "schema": { @@ -46290,22 +49003,44 @@ ] } }, - "/api/organizations/{organizationId}/workload/settings": { - "get": { - "operationId": "workload_settings_get", + "/api/organizations/{organizationId}/procurement/returns/{id}/reconcile": { + "post": { + "operationId": "procurement_reconcile_return", "tags": [ - "WorkloadCapacity" + "Procurement" ], "responses": { "200": { - "description": "WorkloadCapacity resource", + "description": "Procurement resource created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/WorkloadCapacity.CapacityOutput.jsonld" + "$ref": "#/components/schemas/Procurement.ProcurementReturnOutput.jsonld-procurement.read" } } - } + }, + "links": {} + }, + "400": { + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} }, "403": { "description": "Forbidden", @@ -46328,8 +49063,108 @@ }, "links": {} }, - "404": { - "description": "Not found", + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + } + }, + "summary": "Explicitly reconcile a physical supply return", + "description": "Explicitly reconcile a physical supply return", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "Procurement identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "id", + "in": "path", + "description": "Procurement identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], + "requestBody": { + "description": "The new Procurement resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Procurement.IndividualizeReceiptInput-procurement.write" + } + } + }, + "required": true + } + } + }, + "/api/organizations/{organizationId}/procurement/suppliers": { + "get": { + "operationId": "procurement_suppliers", + "tags": [ + "Procurement" + ], + "responses": { + "200": { + "description": "Procurement collection", + "content": { + "application/ld+json": { + "schema": { + "type": "object", + "description": "Procurement.SupplierOutput.jsonld-procurement.read collection.", + "allOf": [ + { + "$ref": "#/components/schemas/HydraCollectionBaseSchema" + }, + { + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Procurement.SupplierOutput.jsonld-procurement.read" + } + } + } + } + ] + } + } + } + }, + "403": { + "description": "Forbidden", "content": { "application/ld+json": { "schema": { @@ -46350,13 +49185,13 @@ "links": {} } }, - "summary": "Retrieves a WorkloadCapacity resource.", - "description": "Retrieves a WorkloadCapacity resource.", + "summary": "List suppliers", + "description": "List suppliers", "parameters": [ { "name": "organizationId", "in": "path", - "description": "WorkloadCapacity identifier", + "description": "Procurement identifier", "required": true, "deprecated": false, "schema": { @@ -46364,21 +49199,78 @@ }, "style": "simple", "explode": false + }, + { + "name": "page", + "in": "query", + "description": "The collection page number", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 1 + }, + "style": "form", + "explode": true + }, + { + "name": "itemsPerPage", + "in": "query", + "description": "The number of items per page", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 30, + "minimum": 0, + "maximum": 100 + }, + "style": "form", + "explode": true + }, + { + "name": "search", + "in": "query", + "description": "Procurement search", + "required": false, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "form", + "explode": true + }, + { + "name": "archived", + "in": "query", + "description": "false or omitted selects active suppliers; true selects archived suppliers; all selects both.", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "enum": [ + "false", + "true", + "all" + ] + }, + "style": "form", + "explode": true } ] }, "post": { - "operationId": "workload_settings_save", + "operationId": "procurement_create_supplier", "tags": [ - "WorkloadCapacity" + "Procurement" ], "responses": { "201": { - "description": "WorkloadCapacity resource created", + "description": "Procurement resource created", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/WorkloadCapacity.CapacityChangeOutput.jsonld" + "$ref": "#/components/schemas/Procurement.SupplierOutput.jsonld-procurement.read" } } }, @@ -46448,13 +49340,13 @@ "links": {} } }, - "summary": "Creates a WorkloadCapacity resource.", - "description": "Creates a WorkloadCapacity resource.", + "summary": "Create an internal supplier", + "description": "Create an internal supplier", "parameters": [ { "name": "organizationId", "in": "path", - "description": "WorkloadCapacity identifier", + "description": "Procurement identifier", "required": true, "deprecated": false, "schema": { @@ -46465,212 +49357,10805 @@ } ], "requestBody": { - "description": "The new WorkloadCapacity resource", + "description": "The new Procurement resource", "content": { "application/ld+json": { "schema": { - "$ref": "#/components/schemas/WorkloadCapacity.CapacityWeekInput" + "$ref": "#/components/schemas/Procurement.CreateSupplierInput-procurement.write" } } }, "required": true } } - } - }, - "components": { - "schemas": { - "AddressSuggestionOutput.jsonld-facility.read": { - "type": "object", - "properties": { - "displayName": { - "description": "Constructor.", - "type": "string" - }, - "latitude": { - "description": "Constructor.", - "type": "number" - }, - "longitude": { - "description": "Constructor.", - "type": "number" - }, - "street": { - "description": "Constructor.", - "type": "string" - }, - "city": { - "description": "Constructor.", - "type": "string" - }, - "region": { - "description": "Constructor.", - "type": "string" - }, - "postalCode": { - "description": "Constructor.", - "type": "string" + }, + "/api/organizations/{organizationId}/procurement/suppliers/{id}": { + "get": { + "operationId": "procurement_supplier", + "tags": [ + "Procurement" + ], + "responses": { + "200": { + "description": "Procurement resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Procurement.SupplierOutput.jsonld-procurement.read" + } + } + } }, - "country": { - "description": "Constructor.", - "type": "string" + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} }, - "countryCode": { - "description": "Constructor.", - "type": "string" + "404": { + "description": "Not found", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} } - } - }, - "ApprovalActionType.ApprovalActionTypeOutput.jsonld-Approval.read": { - "allOf": [ + }, + "summary": "Read a supplier", + "description": "Read a supplier", + "parameters": [ { - "$ref": "#/components/schemas/HydraItemBaseSchema" + "name": "organizationId", + "in": "path", + "description": "Procurement identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false }, { - "type": "object", - "properties": { - "value": { - "readOnly": true, - "description": "Property value.", - "default": "", - "type": "string" - }, - "label": { - "readOnly": true, - "description": "Property label.", - "default": "", - "type": "string" - } - } + "name": "id", + "in": "path", + "description": "Procurement identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false } - ], - "description": "Reference catalog of regulated action types the approval policy can gate." + ] }, - "ApprovalRequest.ApprovalRequestOutput.jsonld-Approval.read": { - "allOf": [ - { - "$ref": "#/components/schemas/HydraItemBaseSchema" + "patch": { + "operationId": "procurement_change_supplier", + "tags": [ + "Procurement" + ], + "responses": { + "200": { + "description": "Procurement resource updated", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Procurement.SupplierOutput.jsonld-procurement.read" + } + } + }, + "links": {} }, - { - "type": "object", - "properties": { - "id": { - "readOnly": true, - "description": "Property id.", - "default": "", - "type": "string" - }, - "organizationId": { - "readOnly": true, - "description": "Property organizationId.", - "default": "", - "type": "string" - }, - "actionType": { - "readOnly": true, - "description": "Property actionType.", - "default": "", - "type": "string" - }, - "subjectId": { - "readOnly": true, - "description": "Property subjectId.", - "default": "", - "type": "string" + "400": { + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } }, - "status": { - "readOnly": true, - "description": "Property status.", - "default": "", - "type": "string" + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "404": { + "description": "Not found", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + } + }, + "summary": "Change a supplier", + "description": "Change a supplier", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "Procurement identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "id", + "in": "path", + "description": "Procurement identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], + "requestBody": { + "description": "The updated Procurement resource", + "content": { + "application/merge-patch+json": { + "schema": { + "$ref": "#/components/schemas/Procurement.ChangeSupplierInput-procurement.write.jsonMergePatch" + } + } + }, + "required": true + } + } + }, + "/api/organizations/{organizationId}/procurement/suppliers/{id}/archive": { + "post": { + "operationId": "procurement_archive_supplier", + "tags": [ + "Procurement" + ], + "responses": { + "200": { + "description": "Procurement resource created", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Procurement.SupplierOutput.jsonld-procurement.read" + } + } + }, + "links": {} + }, + "400": { + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + } + }, + "summary": "Archive a supplier retaining current purchases", + "description": "Archive a supplier retaining current purchases", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "Procurement identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "id", + "in": "path", + "description": "Procurement identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ] + } + }, + "/api/publications": { + "post": { + "operationId": "publication_create", + "tags": [ + "Publication" + ], + "responses": { + "202": { + "description": "Publication resource created", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Publication.PublicationOutput.jsonld" + } + } + }, + "links": {} + }, + "400": { + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + } + }, + "summary": "Creates a Publication resource.", + "description": "Creates a Publication resource.", + "parameters": [], + "requestBody": { + "description": "The new Publication resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Publication.CreatePublicationInput" + } + } + }, + "required": true + } + } + }, + "/api/publications/{id}": { + "get": { + "operationId": "publication_get", + "tags": [ + "Publication" + ], + "responses": { + "200": { + "description": "Publication resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Publication.PublicationOutput.jsonld" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "404": { + "description": "Not found", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + } + }, + "summary": "Retrieves a Publication resource.", + "description": "Retrieves a Publication resource.", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Publication identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ] + } + }, + "/api/auth/register": { + "post": { + "operationId": "register_user", + "tags": [ + "Registration" + ], + "responses": { + "201": { + "description": "Account created. A verification code has been sent to the email.", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Registration.RegisterOutput.jsonld-token.read" + } + } + }, + "links": { + "ConfirmRegistration": { + "operationId": "confirm_registration", + "description": "Verify the email using the token and code" + } + } + }, + "400": { + "description": "Invalid request - validation failed", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "409": { + "description": "An account already exists with this email" + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + }, + "429": { + "description": "Too many registration requests" + } + }, + "summary": "Register a new account", + "description": "Creates a pending-verification account and emails an OTP verification code.", + "parameters": [], + "requestBody": { + "description": "The new Registration resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Registration.RegisterInput-registration.write" + } + } + }, + "required": true + } + } + }, + "/api/auth/register/resend": { + "post": { + "operationId": "resend_registration", + "tags": [ + "Registration" + ], + "responses": { + "200": { + "description": "A new verification code has been sent.", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Registration.RegisterOutput.jsonld-token.read" + } + } + }, + "links": {} + }, + "400": { + "description": "Invalid request - missing or invalid token", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "404": { + "description": "Verification challenge not found or no longer valid" + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + }, + "429": { + "description": "Resend cooldown not yet elapsed" + } + }, + "summary": "Resend the verification code", + "description": "Resends the email-verification OTP code using the existing challenge token.", + "parameters": [], + "requestBody": { + "description": "The new Registration resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Registration.ResendRegistrationInput-registration.write" + } + } + }, + "required": true + } + } + }, + "/api/auth/register/verify": { + "post": { + "operationId": "confirm_registration", + "tags": [ + "Registration" + ], + "responses": { + "200": { + "description": "Email verified. Access token returned and refresh token cookie set.", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Registration.LoginOutput.jsonld-token.read" + } + } + }, + "links": { + "RefreshToken": { + "operationId": "refresh", + "description": "Use the refresh token cookie to get a new access token" + } + } + }, + "400": { + "description": "Invalid request - missing or invalid parameters", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "401": { + "description": "Invalid or expired token/code" + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + }, + "429": { + "description": "Maximum verification attempts exceeded" + } + }, + "summary": "Verify email and log in", + "description": "Verifies the OTP code, activates the account, and returns an access token (and refresh token cookie) so the user is logged in automatically.", + "parameters": [], + "requestBody": { + "description": "The new Registration resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Registration.ConfirmRegistrationInput-registration.write" + } + } + }, + "required": true + } + } + }, + "/api/organizations/{organizationId}/compliance/export": { + "get": { + "operationId": "exportOrganizationSafetyRegister", + "tags": [ + "SafetyRegisterExport" + ], + "responses": { + "204": { + "description": "SafetyRegisterExport resource" + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "404": { + "description": "Not found", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + } + }, + "summary": "Retrieves a SafetyRegisterExport resource.", + "description": "Retrieves a SafetyRegisterExport resource.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "SafetyRegisterExport identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ] + } + }, + "/api/organizations/{organizationId}/facilities/{facilityId}/compliance/export": { + "get": { + "operationId": "exportFacilitySafetyRegister", + "tags": [ + "SafetyRegisterExport" + ], + "responses": { + "204": { + "description": "SafetyRegisterExport resource" + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "404": { + "description": "Not found", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + } + }, + "summary": "Retrieves a SafetyRegisterExport resource.", + "description": "Retrieves a SafetyRegisterExport resource.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "SafetyRegisterExport identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "facilityId", + "in": "path", + "description": "SafetyRegisterExport identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ] + } + }, + "/api/organizations/{organizationId}/service-requests": { + "get": { + "operationId": "list_service_requests", + "tags": [ + "ServiceRequest" + ], + "responses": { + "200": { + "description": "ServiceRequest collection", + "content": { + "application/ld+json": { + "schema": { + "type": "object", + "description": "ServiceRequest.ServiceRequestOutput.jsonld-service_request.read collection.", + "allOf": [ + { + "$ref": "#/components/schemas/HydraCollectionBaseSchema" + }, + { + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ServiceRequest.ServiceRequestOutput.jsonld-service_request.read" + } + } + } + } + ] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + } + }, + "summary": "list an internal repair request", + "description": "list an internal repair request", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "ServiceRequest identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "page", + "in": "query", + "description": "The collection page number", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 1 + }, + "style": "form", + "explode": true + }, + { + "name": "itemsPerPage", + "in": "query", + "description": "The number of items per page", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 30, + "minimum": 0, + "maximum": 100 + }, + "style": "form", + "explode": true + }, + { + "name": "status", + "in": "query", + "description": "ServiceRequest status", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "enum": [ + "requested", + "qualified", + "rejected", + "cancelled", + "converted" + ] + }, + "style": "form", + "explode": true + }, + { + "name": "equipmentId", + "in": "query", + "description": "ServiceRequest equipmentId", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "uuid" + }, + "style": "form", + "explode": true + }, + { + "name": "siteId", + "in": "query", + "description": "ServiceRequest siteId", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "format": "uuid" + }, + "style": "form", + "explode": true + }, + { + "name": "search", + "in": "query", + "description": "ServiceRequest search", + "required": false, + "deprecated": false, + "schema": { + "type": "string", + "maxLength": 160 + }, + "style": "form", + "explode": true + } + ] + }, + "post": { + "operationId": "create_service_request", + "tags": [ + "ServiceRequest" + ], + "responses": { + "201": { + "description": "ServiceRequest resource created", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ServiceRequest.ServiceRequestOutput.jsonld-service_request.read" + } + } + }, + "links": {} + }, + "400": { + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + } + }, + "summary": "create an internal repair request", + "description": "create an internal repair request", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "ServiceRequest identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], + "requestBody": { + "description": "The new ServiceRequest resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ServiceRequest.CreateServiceRequestInput-service_request.write" + } + } + }, + "required": true + } + } + }, + "/api/organizations/{organizationId}/service-requests/{id}": { + "get": { + "operationId": "get_service_request", + "tags": [ + "ServiceRequest" + ], + "responses": { + "200": { + "description": "ServiceRequest resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ServiceRequest.ServiceRequestOutput.jsonld-service_request.read" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "404": { + "description": "Not found", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + } + }, + "summary": "get an internal repair request", + "description": "get an internal repair request", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "ServiceRequest identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "id", + "in": "path", + "description": "ServiceRequest identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ] + }, + "patch": { + "operationId": "patch_service_request", + "tags": [ + "ServiceRequest" + ], + "responses": { + "200": { + "description": "ServiceRequest resource updated", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ServiceRequest.ServiceRequestOutput.jsonld-service_request.read" + } + } + }, + "links": {} + }, + "400": { + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "404": { + "description": "Not found", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + } + }, + "summary": "patch an internal repair request", + "description": "patch an internal repair request", + "parameters": [ + { + "name": "If-Match", + "in": "header", + "description": "", + "required": true, + "deprecated": false, + "schema": { + "type": "string", + "example": "\"revision-1\"" + }, + "style": "simple", + "explode": false + }, + { + "name": "organizationId", + "in": "path", + "description": "ServiceRequest identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "id", + "in": "path", + "description": "ServiceRequest identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], + "requestBody": { + "description": "The updated ServiceRequest resource", + "content": { + "application/merge-patch+json": { + "schema": { + "$ref": "#/components/schemas/ServiceRequest.UpdateServiceRequestInput-service_request.write.jsonMergePatch" + } + } + }, + "required": true + } + } + }, + "/api/organizations/{organizationId}/service-requests/{id}/cancel": { + "post": { + "operationId": "cancel_service_request", + "tags": [ + "ServiceRequest" + ], + "responses": { + "200": { + "description": "ServiceRequest resource created", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ServiceRequest.ServiceRequestOutput.jsonld-service_request.read" + } + } + }, + "links": {} + }, + "400": { + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + } + }, + "summary": "cancel an internal repair request", + "description": "cancel an internal repair request", + "parameters": [ + { + "name": "If-Match", + "in": "header", + "description": "", + "required": true, + "deprecated": false, + "schema": { + "type": "string", + "example": "\"revision-1\"" + }, + "style": "simple", + "explode": false + }, + { + "name": "organizationId", + "in": "path", + "description": "ServiceRequest identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "id", + "in": "path", + "description": "ServiceRequest identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], + "requestBody": { + "description": "The new ServiceRequest resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ServiceRequest.DecisionServiceRequestInput-service_request.write" + } + } + }, + "required": true + } + } + }, + "/api/organizations/{organizationId}/service-requests/{id}/convert": { + "post": { + "operationId": "convert_service_request", + "tags": [ + "ServiceRequest" + ], + "responses": { + "200": { + "description": "ServiceRequest resource created", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ServiceRequest.ServiceRequestOutput.jsonld-service_request.read" + } + } + }, + "links": {} + }, + "400": { + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + } + }, + "summary": "convert an internal repair request", + "description": "convert an internal repair request", + "parameters": [ + { + "name": "If-Match", + "in": "header", + "description": "", + "required": true, + "deprecated": false, + "schema": { + "type": "string", + "example": "\"revision-1\"" + }, + "style": "simple", + "explode": false + }, + { + "name": "organizationId", + "in": "path", + "description": "ServiceRequest identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "id", + "in": "path", + "description": "ServiceRequest identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], + "requestBody": { + "description": "The new ServiceRequest resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ServiceRequest.ConvertServiceRequestInput-service_request.write" + } + } + }, + "required": true + } + } + }, + "/api/organizations/{organizationId}/service-requests/{id}/qualify": { + "post": { + "operationId": "qualify_service_request", + "tags": [ + "ServiceRequest" + ], + "responses": { + "200": { + "description": "ServiceRequest resource created", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ServiceRequest.ServiceRequestOutput.jsonld-service_request.read" + } + } + }, + "links": {} + }, + "400": { + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + } + }, + "summary": "qualify an internal repair request", + "description": "qualify an internal repair request", + "parameters": [ + { + "name": "If-Match", + "in": "header", + "description": "", + "required": true, + "deprecated": false, + "schema": { + "type": "string", + "example": "\"revision-1\"" + }, + "style": "simple", + "explode": false + }, + { + "name": "organizationId", + "in": "path", + "description": "ServiceRequest identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "id", + "in": "path", + "description": "ServiceRequest identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], + "requestBody": { + "description": "The new ServiceRequest resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ServiceRequest.QualifyServiceRequestInput-service_request.write" + } + } + }, + "required": true + } + } + }, + "/api/organizations/{organizationId}/service-requests/{id}/reject": { + "post": { + "operationId": "reject_service_request", + "tags": [ + "ServiceRequest" + ], + "responses": { + "200": { + "description": "ServiceRequest resource created", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ServiceRequest.ServiceRequestOutput.jsonld-service_request.read" + } + } + }, + "links": {} + }, + "400": { + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + } + }, + "summary": "reject an internal repair request", + "description": "reject an internal repair request", + "parameters": [ + { + "name": "If-Match", + "in": "header", + "description": "", + "required": true, + "deprecated": false, + "schema": { + "type": "string", + "example": "\"revision-1\"" + }, + "style": "simple", + "explode": false + }, + { + "name": "organizationId", + "in": "path", + "description": "ServiceRequest identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "id", + "in": "path", + "description": "ServiceRequest identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], + "requestBody": { + "description": "The new ServiceRequest resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ServiceRequest.DecisionServiceRequestInput-service_request.write" + } + } + }, + "required": true + } + } + }, + "/api/sessions": { + "get": { + "operationId": "session_list", + "tags": [ + "Sessions" + ], + "responses": { + "200": { + "description": "List of sessions retrieved successfully", + "content": { + "application/ld+json": { + "schema": { + "type": "object", + "description": "Session.SessionOutput.jsonld-session.read collection.", + "allOf": [ + { + "$ref": "#/components/schemas/HydraCollectionBaseSchema" + }, + { + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Session.SessionOutput.jsonld-session.read" + } + } + } + } + ] + } + } + } + }, + "401": { + "description": "Authentication required" + }, + "403": { + "description": "Insufficient permissions" + } + }, + "summary": "List sessions", + "description": "Returns all active sessions for the authenticated user.", + "parameters": [ + { + "name": "page", + "in": "query", + "description": "The collection page number", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 1 + }, + "style": "form", + "explode": true + }, + { + "name": "itemsPerPage", + "in": "query", + "description": "The number of items per page", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 30, + "minimum": 0, + "maximum": 100 + }, + "style": "form", + "explode": true + } + ], + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/api/sessions/revoke-all": { + "post": { + "operationId": "session_revoke_all", + "tags": [ + "Sessions" + ], + "responses": { + "204": { + "description": "All sessions revoked successfully", + "links": {} + }, + "400": { + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "401": { + "description": "Authentication required" + }, + "403": { + "description": "Insufficient permissions" + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + } + }, + "summary": "Revoke all sessions", + "description": "Revoke all sessions for the authenticated user (logout from all devices).", + "parameters": [], + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/api/sessions/revoke-others": { + "post": { + "operationId": "session_revoke_others", + "tags": [ + "Sessions" + ], + "responses": { + "200": { + "description": "Other sessions revoked successfully", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Session.RevokeOtherSessionsOutput.jsonld-session.revoke_others" + } + } + }, + "links": {} + }, + "400": { + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "401": { + "description": "Authentication required" + }, + "403": { + "description": "Insufficient permissions" + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + } + }, + "summary": "Revoke other sessions", + "description": "Revokes every active session of the authenticated user except the one backing the current request, signing the user out on every other device while staying signed in here. Idempotent: calling it again once there is nothing else to revoke returns a zero count.", + "parameters": [], + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/api/sessions/{id}": { + "get": { + "operationId": "session_get", + "tags": [ + "Sessions" + ], + "responses": { + "200": { + "description": "Session retrieved successfully", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Session.SessionOutput.jsonld-session.read" + } + } + } + }, + "401": { + "description": "Authentication required" + }, + "403": { + "description": "Insufficient permissions" + }, + "404": { + "description": "Session not found" + } + }, + "summary": "Get session", + "description": "Get details of a specific session.", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Session identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], + "security": [ + { + "bearerAuth": [] + } + ] + }, + "delete": { + "operationId": "session_revoke", + "tags": [ + "Sessions" + ], + "responses": { + "204": { + "description": "Session revoked successfully" + }, + "401": { + "description": "Authentication required" + }, + "403": { + "description": "Insufficient permissions" + }, + "404": { + "description": "Session not found" + } + }, + "summary": "Revoke session", + "description": "Revoke a specific session. The user will be logged out from that device.", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Session identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/api/organizations/{organizationId}/teams": { + "get": { + "operationId": "listTeams", + "tags": [ + "Teams" + ], + "responses": { + "200": { + "description": "Team collection", + "content": { + "application/ld+json": { + "schema": { + "type": "object", + "description": "Team.TeamOutput.jsonld-Organization.read collection.", + "allOf": [ + { + "$ref": "#/components/schemas/HydraCollectionBaseSchema" + }, + { + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Team.TeamOutput.jsonld-Organization.read" + } + } + } + } + ] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + } + }, + "summary": "List teams", + "description": "Lists all teams defined for an organization. Requires organization.teams.read.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "Team identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "page", + "in": "query", + "description": "The collection page number", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 1 + }, + "style": "form", + "explode": true + } + ] + }, + "post": { + "operationId": "createTeam", + "tags": [ + "Teams" + ], + "responses": { + "201": { + "description": "Team created", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Team.TeamOutput.jsonld-Organization.read" + } + } + }, + "links": {} + }, + "400": { + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "403": { + "description": "Insufficient permissions" + }, + "404": { + "description": "Organization not found" + }, + "409": { + "description": "A team with this name already exists in the organization" + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + } + }, + "summary": "Create team", + "description": "Creates a team inside an organization. Requires organization.teams.write.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "Team identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], + "requestBody": { + "description": "The new Team resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Team.CreateTeamInput-Organization.write" + } + } + }, + "required": true + } + } + }, + "/api/organizations/{organizationId}/teams/{teamId}": { + "get": { + "operationId": "getTeam", + "tags": [ + "Teams" + ], + "responses": { + "200": { + "description": "Team retrieved", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Team.TeamOutput.jsonld-Organization.read" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "404": { + "description": "Organization or team not found" + } + }, + "summary": "Get team", + "description": "Returns a single team by identifier. Requires organization.teams.read.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "Team identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "teamId", + "in": "path", + "description": "Team identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ] + }, + "delete": { + "operationId": "deleteTeam", + "tags": [ + "Teams" + ], + "responses": { + "204": { + "description": "Team deleted" + }, + "403": { + "description": "Insufficient permissions" + }, + "404": { + "description": "Organization or team not found" + } + }, + "summary": "Delete team", + "description": "Permanently deletes a team. All team member assignments are removed. Requires organization.teams.manage.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "Team identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "teamId", + "in": "path", + "description": "Team identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ] + }, + "patch": { + "operationId": "updateTeam", + "tags": [ + "Teams" + ], + "responses": { + "200": { + "description": "Team resource updated", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Team.TeamOutput.jsonld-Organization.read" + } + } + }, + "links": {} + }, + "400": { + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "404": { + "description": "Not found", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + } + }, + "summary": "Update team", + "description": "Renames or redescribes a team. Requires organization.teams.write.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "Team identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "teamId", + "in": "path", + "description": "Team identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], + "requestBody": { + "description": "The updated Team resource", + "content": { + "application/merge-patch+json": { + "schema": { + "$ref": "#/components/schemas/Team.UpdateTeamInput-Organization.write.jsonMergePatch" + } + } + }, + "required": true + } + } + }, + "/api/organizations/{organizationId}/teams/{teamId}/members": { + "get": { + "operationId": "listTeamMembers", + "tags": [ + "Teams" + ], + "responses": { + "200": { + "description": "Team collection", + "content": { + "application/ld+json": { + "schema": { + "type": "object", + "description": "Team.TeamMemberOutput.jsonld-Organization.read collection.", + "allOf": [ + { + "$ref": "#/components/schemas/HydraCollectionBaseSchema" + }, + { + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Team.TeamMemberOutput.jsonld-Organization.read" + } + } + } + } + ] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + } + }, + "summary": "List team members", + "description": "Lists members assigned to a team. Requires organization.teams.read.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "Team identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "teamId", + "in": "path", + "description": "Team identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "page", + "in": "query", + "description": "The collection page number", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 1 + }, + "style": "form", + "explode": true + } + ] + }, + "post": { + "operationId": "addTeamMember", + "tags": [ + "Teams" + ], + "responses": { + "201": { + "description": "Member added to the team", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Team.TeamMemberOutput.jsonld-Organization.read" + } + } + }, + "links": {} + }, + "400": { + "description": "Member is not active in this organization, or already part of the team", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "403": { + "description": "Insufficient permissions" + }, + "404": { + "description": "Organization, team, or member not found" + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + } + }, + "summary": "Add team member", + "description": "Adds an active organization member to a team. Requires organization.teams.write.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "Team identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "teamId", + "in": "path", + "description": "Team identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], + "requestBody": { + "description": "The new Team resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Team.AddTeamMemberInput-Organization.write" + } + } + }, + "required": true + } + } + }, + "/api/organizations/{organizationId}/teams/{teamId}/members/{memberId}": { + "delete": { + "operationId": "removeTeamMember", + "tags": [ + "Teams" + ], + "responses": { + "204": { + "description": "Member removed from the team" + }, + "403": { + "description": "Insufficient permissions" + }, + "404": { + "description": "Organization, team, or membership not found" + } + }, + "summary": "Remove team member", + "description": "Removes a member from a team. Requires organization.teams.write.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "Team identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "teamId", + "in": "path", + "description": "Team identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "memberId", + "in": "path", + "description": "Team identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ] + } + }, + "/api/tenants": { + "get": { + "operationId": "tenant_list", + "tags": [ + "Tenants" + ], + "responses": { + "200": { + "description": "List of tenants retrieved successfully", + "content": { + "application/ld+json": { + "schema": { + "type": "object", + "description": "Tenant.TenantOutput.jsonld-tenant.read collection.", + "allOf": [ + { + "$ref": "#/components/schemas/HydraCollectionBaseSchema" + }, + { + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Tenant.TenantOutput.jsonld-tenant.read" + } + } + } + } + ] + } + } + } + }, + "401": { + "description": "Authentication required" + }, + "403": { + "description": "Insufficient permissions" + } + }, + "summary": "List tenants", + "description": "Returns a list of all tenants. Requires tenants.read permission.", + "parameters": [ + { + "name": "page", + "in": "query", + "description": "The collection page number", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 1 + }, + "style": "form", + "explode": true + }, + { + "name": "itemsPerPage", + "in": "query", + "description": "The number of items per page", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 30, + "minimum": 0, + "maximum": 100 + }, + "style": "form", + "explode": true + } + ], + "security": [ + { + "bearerAuth": [] + } + ] + }, + "post": { + "operationId": "tenant_create", + "tags": [ + "Tenants" + ], + "responses": { + "201": { + "description": "Tenant created successfully", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Tenant.TenantOutput.jsonld-tenant.read" + } + } + }, + "links": {} + }, + "400": { + "description": "Invalid request - validation failed", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "401": { + "description": "Authentication required" + }, + "403": { + "description": "Insufficient permissions" + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + } + }, + "summary": "Create tenant", + "description": "Create a new tenant with custom OAuth2 settings.", + "parameters": [], + "requestBody": { + "description": "The new Tenant resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Tenant.TenantInput-tenant.write" + } + } + }, + "required": true + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/api/tenants/{id}": { + "get": { + "operationId": "tenant_get", + "tags": [ + "Tenants" + ], + "responses": { + "200": { + "description": "Tenant retrieved successfully", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Tenant.TenantOutput.jsonld-tenant.read" + } + } + } + }, + "401": { + "description": "Authentication required" + }, + "403": { + "description": "Insufficient permissions" + }, + "404": { + "description": "Tenant not found" + } + }, + "summary": "Get tenant", + "description": "Get details of a specific tenant.", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Tenant identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], + "security": [ + { + "bearerAuth": [] + } + ] + }, + "put": { + "operationId": "tenant_replace", + "tags": [ + "Tenants" + ], + "responses": { + "200": { + "description": "Tenant replaced successfully", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Tenant.TenantOutput.jsonld-tenant.read" + } + } + }, + "links": {} + }, + "400": { + "description": "Invalid request - validation failed", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "401": { + "description": "Authentication required" + }, + "403": { + "description": "Insufficient permissions" + }, + "404": { + "description": "Tenant not found" + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + } + }, + "summary": "Replace tenant", + "description": "Replace a tenant by ID. Requires tenants.update permission.", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Tenant identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], + "requestBody": { + "description": "The updated Tenant resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Tenant.TenantInput-tenant.write" + } + } + }, + "required": true + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "delete": { + "operationId": "tenant_delete", + "tags": [ + "Tenants" + ], + "responses": { + "204": { + "description": "Tenant deleted successfully" + }, + "401": { + "description": "Authentication required" + }, + "403": { + "description": "Insufficient permissions" + }, + "404": { + "description": "Tenant not found" + } + }, + "summary": "Delete tenant", + "description": "Delete a tenant by ID. Requires tenants.delete permission.", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Tenant identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], + "security": [ + { + "bearerAuth": [] + } + ] + }, + "patch": { + "operationId": "tenant_update", + "tags": [ + "Tenants" + ], + "responses": { + "200": { + "description": "Tenant updated successfully", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Tenant.TenantOutput.jsonld-tenant.read" + } + } + }, + "links": {} + }, + "400": { + "description": "Invalid request - validation failed", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "401": { + "description": "Authentication required" + }, + "403": { + "description": "Insufficient permissions" + }, + "404": { + "description": "Tenant not found" + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + } + }, + "summary": "Update tenant", + "description": "Update a tenant by ID. Requires tenants.update permission.", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Tenant identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], + "requestBody": { + "description": "The updated Tenant resource", + "content": { + "application/merge-patch+json": { + "schema": { + "$ref": "#/components/schemas/Tenant.TenantInput-tenant.write.jsonMergePatch" + } + } + }, + "required": true + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/api/tenants/{id}/activate": { + "post": { + "operationId": "tenant_activate", + "tags": [ + "Tenants" + ], + "responses": { + "200": { + "description": "Tenant activated successfully", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Tenant.TenantOutput.jsonld-tenant.read" + } + } + }, + "links": {} + }, + "400": { + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "401": { + "description": "Authentication required" + }, + "403": { + "description": "Insufficient permissions" + }, + "404": { + "description": "Tenant not found" + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + } + }, + "summary": "Activate tenant", + "description": "Activate a tenant by ID. Requires tenants.update permission.", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Tenant identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/api/tenants/{id}/deactivate": { + "post": { + "operationId": "tenant_deactivate", + "tags": [ + "Tenants" + ], + "responses": { + "200": { + "description": "Tenant deactivated successfully", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Tenant.TenantOutput.jsonld-tenant.read" + } + } + }, + "links": {} + }, + "400": { + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "401": { + "description": "Authentication required" + }, + "403": { + "description": "Insufficient permissions" + }, + "404": { + "description": "Tenant not found" + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + } + }, + "summary": "Deactivate tenant", + "description": "Deactivate a tenant by ID. Requires tenants.update permission.", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Tenant identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/api/trusted-devices": { + "get": { + "operationId": "trusted_device_list", + "tags": [ + "Trusted Devices" + ], + "responses": { + "200": { + "description": "List of trusted devices retrieved successfully", + "content": { + "application/ld+json": { + "schema": { + "type": "object", + "description": "TrustedDevice.TrustedDeviceOutput.jsonld-trusted_device.read collection.", + "allOf": [ + { + "$ref": "#/components/schemas/HydraCollectionBaseSchema" + }, + { + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TrustedDevice.TrustedDeviceOutput.jsonld-trusted_device.read" + } + } + } + } + ] + } + } + } + }, + "401": { + "description": "Authentication required" + }, + "403": { + "description": "Insufficient permissions" + } + }, + "summary": "List trusted devices", + "description": "List all trusted devices for the current user.", + "parameters": [ + { + "name": "page", + "in": "query", + "description": "The collection page number", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 1 + }, + "style": "form", + "explode": true + }, + { + "name": "itemsPerPage", + "in": "query", + "description": "The number of items per page", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 30, + "minimum": 0, + "maximum": 100 + }, + "style": "form", + "explode": true + } + ], + "security": [ + { + "bearerAuth": [] + } + ] + }, + "post": { + "operationId": "trusted_device_trust", + "tags": [ + "Trusted Devices" + ], + "responses": { + "201": { + "description": "Device trusted successfully", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/TrustedDevice.TrustDeviceOutput.jsonld-trusted_device.read" + } + } + }, + "links": {} + }, + "400": { + "description": "Invalid request", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "401": { + "description": "Authentication required" + }, + "403": { + "description": "Insufficient permissions" + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + } + }, + "summary": "Trust device", + "description": "Register the current device as trusted.", + "parameters": [], + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/api/trusted-devices/revoke-all": { + "post": { + "operationId": "trusted_device_revoke_all", + "tags": [ + "Trusted Devices" + ], + "responses": { + "204": { + "description": "All devices revoked successfully", + "links": {} + }, + "400": { + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "401": { + "description": "Authentication required" + }, + "403": { + "description": "Insufficient permissions" + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + } + }, + "summary": "Revoke all trusted devices", + "description": "Revoke trust for all devices.", + "parameters": [], + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/api/trusted-devices/{id}": { + "delete": { + "operationId": "trusted_device_revoke", + "tags": [ + "Trusted Devices" + ], + "responses": { + "204": { + "description": "Device revoked successfully" + }, + "401": { + "description": "Authentication required" + }, + "403": { + "description": "Insufficient permissions" + }, + "404": { + "description": "Device not found" + } + }, + "summary": "Revoke trusted device", + "description": "Revoke trust for a specific device.", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "TrustedDevice identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/api/me": { + "get": { + "operationId": "user_get_current_profile", + "tags": [ + "Users" + ], + "responses": { + "200": { + "description": "Current user profile retrieved successfully", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/CurrentUserProfile.CurrentUserProfileOutput.jsonld-user.read" + } + } + } + }, + "401": { + "description": "Authentication required" + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "404": { + "description": "Authenticated user not found" + } + }, + "summary": "Get current user profile", + "description": "Returns the authenticated user profile with resolved global roles and permissions.", + "parameters": [], + "security": [ + { + "bearerAuth": [] + } + ] + }, + "patch": { + "operationId": "user_update_current_profile", + "tags": [ + "Users" + ], + "responses": { + "200": { + "description": "Current user profile updated successfully", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/CurrentUserProfile.CurrentUserProfileOutput.jsonld-user.read" + } + } + }, + "links": {} + }, + "400": { + "description": "Invalid request - validation failed", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "401": { + "description": "Authentication required" + }, + "403": { + "description": "Insufficient permissions" + }, + "404": { + "description": "Authenticated user not found" + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + } + }, + "summary": "Update current user profile", + "description": "Partially updates the authenticated user profile (first name, last name, preferred display language). Requires profile.update permission.", + "parameters": [], + "requestBody": { + "description": "The updated CurrentUserProfile resource", + "content": { + "application/merge-patch+json": { + "schema": { + "$ref": "#/components/schemas/CurrentUserProfile.CurrentUserProfileInput-user.write.jsonMergePatch" + } + } + }, + "required": true + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/api/me/avatar": { + "put": { + "operationId": "user_upload_current_avatar", + "tags": [ + "Users" + ], + "responses": { + "200": { + "description": "Current user avatar uploaded successfully", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/CurrentUserProfile.CurrentUserProfileOutput.jsonld-user.read" + } + } + }, + "links": {} + }, + "400": { + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "401": { + "description": "Authentication required" + }, + "403": { + "description": "Insufficient permissions" + }, + "404": { + "description": "Authenticated user not found" + }, + "422": { + "description": "Invalid file - missing, too large, or unsupported MIME type", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + } + }, + "summary": "Upload current user avatar", + "description": "Replaces the authenticated user avatar. Accepted formats: JPEG, PNG, WebP, GIF (max 5 MB). Requires profile.update permission.", + "parameters": [], + "requestBody": { + "description": "Avatar image file (JPEG, PNG, WebP or GIF, max 5 MB)", + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "required": [ + "avatar" + ], + "properties": { + "avatar": { + "type": "string", + "format": "binary", + "description": "Image file (JPEG, PNG, WebP or GIF), max 5 MB" + } + } + } + } + }, + "required": true + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/api/me/deactivate": { + "post": { + "operationId": "user_deactivate_current", + "tags": [ + "Users" + ], + "responses": { + "200": { + "description": "Account deactivated successfully — all sessions revoked", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/CurrentUserProfile.CurrentUserProfileOutput.jsonld-user.read" + } + } + }, + "links": {} + }, + "400": { + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "401": { + "description": "Authentication required" + }, + "403": { + "description": "Insufficient permissions" + }, + "404": { + "description": "Authenticated user not found" + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + } + }, + "summary": "Deactivate current user account", + "description": "Deactivates the authenticated user own account and revokes all of their active sessions, signing them out everywhere. Idempotent. Organization memberships are left untouched. Reactivation is admin-only (POST /users/{id}/activate) — there is no self-service reactivation. Requires profile.update permission.", + "parameters": [], + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/api/me/email-change": { + "post": { + "operationId": "user_email_change_request", + "tags": [ + "Users" + ], + "responses": { + "202": { + "description": "Confirmation link sent to the new address", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/EmailChange.RequestEmailChangeOutput.jsonld-email_change.read" + } + } + }, + "links": {} + }, + "400": { + "description": "Invalid request - missing or malformed fields", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "401": { + "description": "Authentication required" + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "409": { + "description": "This email address cannot be used" + }, + "422": { + "description": "Current password is incorrect", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + }, + "429": { + "description": "Too many email change requests" + } + }, + "summary": "Request email change", + "description": "Verifies the current password, then sends a confirmation link (1 h validity) to the new address and an alert to the current address. A new request replaces any pending one.", + "parameters": [], + "requestBody": { + "description": "The new EmailChange resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/EmailChange.RequestEmailChangeInput-email_change.write" + } + } + }, + "required": true + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "delete": { + "operationId": "user_email_change_cancel", + "tags": [ + "Users" + ], + "responses": { + "204": { + "description": "Pending request cancelled (or nothing was pending)" + }, + "401": { + "description": "Authentication required" + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "404": { + "description": "Not found", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + } + }, + "summary": "Cancel pending email change", + "description": "Cancels the pending email change request of the authenticated user. Idempotent: answers 204 whether or not a request was pending.", + "parameters": [], + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/api/me/email-change/confirm": { + "post": { + "operationId": "user_email_change_confirm", + "tags": [ + "Users" + ], + "responses": { + "200": { + "description": "Email changed — all sessions have been revoked, sign in again with the new address", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/EmailChange.ConfirmEmailChangeOutput.jsonld-email_change.read" + } + } + }, + "links": {} + }, + "400": { + "description": "Invalid, expired or already-used token", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "409": { + "description": "This email address cannot be used" + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + }, + "429": { + "description": "Too many confirmation attempts" + } + }, + "summary": "Confirm email change", + "description": "Public. Validates the confirmation token received at the new address, applies the change, and revokes every session and OAuth token of the user (the sign-in identifier changed). The token is single-use and expires after 1 hour.", + "parameters": [], + "requestBody": { + "description": "The new EmailChange resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/EmailChange.ConfirmEmailChangeInput-email_change.write" + } + } + }, + "required": true + } + } + }, + "/api/me/password/confirm": { + "post": { + "operationId": "confirm_password_change", + "tags": [ + "Users" + ], + "responses": { + "200": { + "description": "Password changed successfully — all sessions have been revoked", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/PasswordChange.ConfirmPasswordChangeOutput.jsonld-token.read" + } + } + }, + "links": {} + }, + "400": { + "description": "Invalid request - missing or invalid parameters", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "401": { + "description": "Authentication required, or invalid/expired token/code" + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + }, + "429": { + "description": "Too many confirmation attempts or maximum verification attempts exceeded" + } + }, + "summary": "Confirm password change", + "description": "Confirms the password change using the challenge token, the OTP code received by email, and the new password. All sessions and OAuth tokens are revoked afterwards.", + "parameters": [], + "requestBody": { + "description": "The new PasswordChange resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/PasswordChange.ConfirmPasswordChangeInput-password_change.write" + } + } + }, + "required": true + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/api/me/password/request": { + "post": { + "operationId": "request_password_change", + "tags": [ + "Users" + ], + "responses": { + "200": { + "description": "Verification code sent — use the challenge token in the confirm step", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/PasswordChange.RequestPasswordChangeOutput.jsonld-token.read" + } + } + }, + "links": { + "ConfirmChange": { + "operationId": "confirm_password_change", + "description": "Confirm the password change using the token and code" + } + } + }, + "400": { + "description": "Invalid request - missing current password", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "401": { + "description": "Authentication required" + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "422": { + "description": "Current password is incorrect", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + }, + "429": { + "description": "Too many password change requests" + } + }, + "summary": "Request password change", + "description": "Verifies the current password and sends an OTP code to the user's email. The returned challenge token is required for the confirm step.", + "parameters": [], + "requestBody": { + "description": "The new PasswordChange resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/PasswordChange.RequestPasswordChangeInput-password_change.write" + } + } + }, + "required": true + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/api/users": { + "get": { + "operationId": "user_list", + "tags": [ + "Users" + ], + "responses": { + "200": { + "description": "List of users retrieved successfully", + "content": { + "application/ld+json": { + "schema": { + "type": "object", + "description": "User.UserOutput.jsonld-user.read collection.", + "allOf": [ + { + "$ref": "#/components/schemas/HydraCollectionBaseSchema" + }, + { + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "array", + "items": { + "$ref": "#/components/schemas/User.UserOutput.jsonld-user.read" + } + } + } + } + ] + } + } + } + }, + "401": { + "description": "Authentication required" + }, + "403": { + "description": "Insufficient permissions" + } + }, + "summary": "List users", + "description": "Returns a paginated list of users. Requires users.read permission.", + "parameters": [ + { + "name": "page", + "in": "query", + "description": "The collection page number", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 1 + }, + "style": "form", + "explode": true + }, + { + "name": "itemsPerPage", + "in": "query", + "description": "The number of items per page", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 30, + "minimum": 0, + "maximum": 100 + }, + "style": "form", + "explode": true + } + ], + "security": [ + { + "bearerAuth": [] + } + ] + }, + "post": { + "operationId": "user_create", + "tags": [ + "Users" + ], + "responses": { + "201": { + "description": "User created successfully", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/User.UserOutput.jsonld-user.read" + } + } + }, + "links": {} + }, + "400": { + "description": "Invalid request - validation failed", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "401": { + "description": "Authentication required" + }, + "403": { + "description": "Insufficient permissions" + }, + "409": { + "description": "User already exists" + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + } + }, + "summary": "Create user", + "description": "Creates a new user account. Requires users.create permission.", + "parameters": [], + "requestBody": { + "description": "The new User resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/User.UserInput-user.write" + } + } + }, + "required": true + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/api/users/{id}": { + "get": { + "operationId": "user_get", + "tags": [ + "Users" + ], + "responses": { + "200": { + "description": "User retrieved successfully", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/User.UserOutput.jsonld-user.read" + } + } + } + }, + "401": { + "description": "Authentication required" + }, + "403": { + "description": "Insufficient permissions" + }, + "404": { + "description": "User not found" + } + }, + "summary": "Get user", + "description": "Retrieve a user by ID. Requires users.read permission.", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "User identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], + "security": [ + { + "bearerAuth": [] + } + ] + }, + "put": { + "operationId": "user_replace", + "tags": [ + "Users" + ], + "responses": { + "200": { + "description": "User replaced successfully", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/User.UserOutput.jsonld-user.read" + } + } + }, + "links": {} + }, + "400": { + "description": "Invalid request - validation failed", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "401": { + "description": "Authentication required" + }, + "403": { + "description": "Insufficient permissions" + }, + "404": { + "description": "User not found" + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + } + }, + "summary": "Replace user", + "description": "Replace a user by ID. Requires users.update permission.", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "User identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], + "requestBody": { + "description": "The updated User resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/User.UserInput-user.write" + } + } + }, + "required": true + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "delete": { + "operationId": "user_delete", + "tags": [ + "Users" + ], + "responses": { + "204": { + "description": "User deleted successfully" + }, + "401": { + "description": "Authentication required" + }, + "403": { + "description": "Insufficient permissions" + }, + "404": { + "description": "User not found" + } + }, + "summary": "Delete user", + "description": "Delete a user by ID. Requires users.delete permission.", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "User identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], + "security": [ + { + "bearerAuth": [] + } + ] + }, + "patch": { + "operationId": "user_update", + "tags": [ + "Users" + ], + "responses": { + "200": { + "description": "User updated successfully", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/User.UserOutput.jsonld-user.read" + } + } + }, + "links": {} + }, + "400": { + "description": "Invalid request - validation failed", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "401": { + "description": "Authentication required" + }, + "403": { + "description": "Insufficient permissions" + }, + "404": { + "description": "User not found" + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + } + }, + "summary": "Update user (partial)", + "description": "Partially update a user by ID. Requires users.update permission.", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "User identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], + "requestBody": { + "description": "The updated User resource", + "content": { + "application/merge-patch+json": { + "schema": { + "$ref": "#/components/schemas/User.UserInput-user.write.jsonMergePatch" + } + } + }, + "required": true + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/api/users/{id}/activate": { + "post": { + "operationId": "user_activate", + "tags": [ + "Users" + ], + "responses": { + "200": { + "description": "User activated successfully", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/User.UserOutput.jsonld-user.read" + } + } + }, + "links": {} + }, + "400": { + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "401": { + "description": "Authentication required" + }, + "403": { + "description": "Insufficient permissions" + }, + "404": { + "description": "User not found" + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + } + }, + "summary": "Activate user", + "description": "Activate a user account. Requires users.update permission.", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "User identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/api/users/{id}/avatar": { + "get": { + "operationId": "user_get_avatar", + "tags": [ + "Users" + ], + "responses": { + "200": { + "description": "Avatar image (WebP)" + }, + "204": { + "description": "User resource" + }, + "404": { + "description": "User or avatar not found" + } + }, + "summary": "Get user avatar", + "description": "Streams the avatar as WebP. Optional ?size=N query parameter resolved to the nearest variant (256, 128, 64 or 32 px). Public endpoint.", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "User identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ] + }, + "post": { + "operationId": "user_upload_avatar", + "tags": [ + "Users" + ], + "responses": { + "200": { + "description": "Avatar uploaded — user output with updated avatarUrl returned", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/User.UserOutput.jsonld-user.read" + } + } + }, + "links": {} + }, + "400": { + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "401": { + "description": "Authentication required" + }, + "403": { + "description": "Insufficient permissions" + }, + "404": { + "description": "User not found" + }, + "422": { + "description": "Invalid file — missing, too large, or unsupported MIME type", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + } + }, + "summary": "Upload user avatar", + "description": "Uploads an avatar image for a user. Accepted formats: JPEG, PNG, WebP, GIF (max 5 MB). The image is resized to four WebP variants (256, 128, 64, 32 px) with cover crop. Requires users.update permission.", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "User identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], + "requestBody": { + "description": "Avatar image file (JPEG, PNG, WebP or GIF, max 5 MB)", + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "required": [ + "avatar" + ], + "properties": { + "avatar": { + "type": "string", + "format": "binary", + "description": "Image file (JPEG, PNG, WebP or GIF), max 5 MB" + } + } + } + } + }, + "required": true + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/api/users/{id}/avatar/{size}.webp": { + "get": { + "operationId": "user_get_avatar_sized", + "tags": [ + "Users" + ], + "responses": { + "200": { + "description": "Avatar image (WebP)" + }, + "204": { + "description": "User resource" + }, + "404": { + "description": "User or avatar not found" + } + }, + "summary": "Get user avatar (sized)", + "description": "Streams the avatar WebP variant closest to the requested size (256, 128, 64 or 32 px). Public endpoint.", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "User identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "size", + "in": "path", + "description": "User identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ] + } + }, + "/api/users/{id}/deactivate": { + "post": { + "operationId": "user_deactivate", + "tags": [ + "Users" + ], + "responses": { + "200": { + "description": "User deactivated successfully", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/User.UserOutput.jsonld-user.read" + } + } + }, + "links": {} + }, + "400": { + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "401": { + "description": "Authentication required" + }, + "403": { + "description": "Insufficient permissions" + }, + "404": { + "description": "User not found" + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + } + }, + "summary": "Deactivate user", + "description": "Deactivate a user account. Requires users.update permission.", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "User identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/api/users/{id}/verify-email": { + "post": { + "operationId": "user_verify_email", + "tags": [ + "Users" + ], + "responses": { + "200": { + "description": "User email verified successfully", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/User.UserOutput.jsonld-user.read" + } + } + }, + "links": {} + }, + "400": { + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "401": { + "description": "Authentication required" + }, + "403": { + "description": "Insufficient permissions" + }, + "404": { + "description": "User not found" + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + } + }, + "summary": "Verify user email", + "description": "Mark a user email as verified. Requires users.update permission.", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "User identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "/api/organizations/{organizationId}/webhooks": { + "get": { + "operationId": "listWebhookSubscriptions", + "tags": [ + "Webhooks" + ], + "responses": { + "200": { + "description": "WebhookSubscription collection", + "content": { + "application/ld+json": { + "schema": { + "type": "object", + "description": "WebhookSubscription.WebhookSubscriptionOutput.jsonld-Webhook.read collection.", + "allOf": [ + { + "$ref": "#/components/schemas/HydraCollectionBaseSchema" + }, + { + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WebhookSubscription.WebhookSubscriptionOutput.jsonld-Webhook.read" + } + } + } + } + ] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + } + }, + "summary": "List webhook subscriptions", + "description": "Lists an organization's webhook subscriptions. Requires organization.webhooks.read.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "WebhookSubscription identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "page", + "in": "query", + "description": "The collection page number", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 1 + }, + "style": "form", + "explode": true + }, + { + "name": "itemsPerPage", + "in": "query", + "description": "The number of items per page", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 30, + "minimum": 0, + "maximum": 100 + }, + "style": "form", + "explode": true + } + ] + }, + "post": { + "operationId": "createWebhookSubscription", + "tags": [ + "Webhooks" + ], + "responses": { + "201": { + "description": "Subscription created, secret revealed once", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/WebhookSubscription.WebhookSecretOutput.jsonld-Webhook.read" + } + } + }, + "links": {} + }, + "400": { + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "403": { + "description": "Insufficient permissions" + }, + "422": { + "description": "Invalid URL, unknown event type, or subscription cap reached", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + } + }, + "summary": "Create webhook subscription", + "description": "Creates an outbound webhook subscription. Requires organization.webhooks.manage. The response includes the PLAINTEXT signing secret exactly once.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "WebhookSubscription identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], + "requestBody": { + "description": "The new WebhookSubscription resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/WebhookSubscription.CreateWebhookSubscriptionInput-Webhook.write" + } + } + }, + "required": true + } + } + }, + "/api/organizations/{organizationId}/webhooks/{webhookId}": { + "get": { + "operationId": "getWebhookSubscription", + "tags": [ + "Webhooks" + ], + "responses": { + "200": { + "description": "Subscription retrieved", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/WebhookSubscription.WebhookSubscriptionOutput.jsonld-Webhook.read" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "404": { + "description": "Organization or subscription not found" + } + }, + "summary": "Get webhook subscription", + "description": "Returns a single webhook subscription. Requires organization.webhooks.read.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "WebhookSubscription identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "webhookId", + "in": "path", + "description": "WebhookSubscription identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ] + }, + "delete": { + "operationId": "deleteWebhookSubscription", + "tags": [ + "Webhooks" + ], + "responses": { + "204": { + "description": "Subscription deleted" + }, + "403": { + "description": "Insufficient permissions" + }, + "404": { + "description": "Organization or subscription not found" + } + }, + "summary": "Delete webhook subscription", + "description": "Permanently deletes a webhook subscription and its delivery log. Requires organization.webhooks.manage.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "WebhookSubscription identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "webhookId", + "in": "path", + "description": "WebhookSubscription identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ] + }, + "patch": { + "operationId": "updateWebhookSubscription", + "tags": [ + "Webhooks" + ], + "responses": { + "200": { + "description": "WebhookSubscription resource updated", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/WebhookSubscription.WebhookSubscriptionOutput.jsonld-Webhook.read" + } + } + }, + "links": {} + }, + "400": { + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "404": { + "description": "Not found", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + } + }, + "summary": "Update webhook subscription", + "description": "Partially updates a webhook subscription (url, eventTypes, isActive, description). Requires organization.webhooks.manage.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "WebhookSubscription identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "webhookId", + "in": "path", + "description": "WebhookSubscription identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], + "requestBody": { + "description": "The updated WebhookSubscription resource", + "content": { + "application/merge-patch+json": { + "schema": { + "$ref": "#/components/schemas/WebhookSubscription.UpdateWebhookSubscriptionInput-Webhook.write.jsonMergePatch" + } + } + }, + "required": true + } + } + }, + "/api/organizations/{organizationId}/webhooks/{webhookId}/deliveries": { + "get": { + "operationId": "listWebhookDeliveries", + "tags": [ + "Webhooks" + ], + "responses": { + "200": { + "description": "WebhookDelivery collection", + "content": { + "application/ld+json": { + "schema": { + "type": "object", + "description": "WebhookDelivery.WebhookDeliveryOutput.jsonld-Webhook.read collection.", + "allOf": [ + { + "$ref": "#/components/schemas/HydraCollectionBaseSchema" + }, + { + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WebhookDelivery.WebhookDeliveryOutput.jsonld-Webhook.read" + } + } + } + } + ] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + } + }, + "summary": "List webhook deliveries", + "description": "Lists a subscription's delivery log. Requires organization.webhooks.read.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "WebhookDelivery identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "webhookId", + "in": "path", + "description": "WebhookDelivery identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "page", + "in": "query", + "description": "The collection page number", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 1 + }, + "style": "form", + "explode": true + }, + { + "name": "itemsPerPage", + "in": "query", + "description": "The number of items per page", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "default": 30, + "minimum": 0, + "maximum": 100 + }, + "style": "form", + "explode": true + }, + { + "name": "status", + "in": "query", + "description": "Delivery status filter (pending|delivered|failed).", + "required": false, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "form", + "explode": true + } + ] + } + }, + "/api/organizations/{organizationId}/webhooks/{webhookId}/deliveries/{deliveryId}/redeliver": { + "post": { + "operationId": "redeliverWebhook", + "tags": [ + "Webhooks" + ], + "responses": { + "202": { + "description": "Redelivery enqueued", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/WebhookDelivery.WebhookPingOutput.jsonld-Webhook.read" + } + } + }, + "links": {} + }, + "400": { + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "404": { + "description": "Organization, subscription, or delivery not found" + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + } + }, + "summary": "Redeliver a webhook delivery", + "description": "Re-enqueues a delivery (any status, including terminally failed) for another attempt. Requires organization.webhooks.manage.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "WebhookDelivery identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "webhookId", + "in": "path", + "description": "WebhookDelivery identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "deliveryId", + "in": "path", + "description": "WebhookDelivery identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ] + } + }, + "/api/organizations/{organizationId}/webhooks/{webhookId}/ping": { + "post": { + "operationId": "pingWebhookSubscription", + "tags": [ + "Webhooks" + ], + "responses": { + "202": { + "description": "Test delivery enqueued", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/WebhookSubscription.WebhookPingOutput.jsonld-Webhook.read" + } + } + }, + "links": {} + }, + "400": { + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + } + }, + "summary": "Send a test delivery", + "description": "Enqueues a synthetic `webhook.ping` test delivery to this subscription. Requires organization.webhooks.manage.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "WebhookSubscription identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "webhookId", + "in": "path", + "description": "WebhookSubscription identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ] + } + }, + "/api/organizations/{organizationId}/webhooks/{webhookId}/rotate-secret": { + "post": { + "operationId": "rotateWebhookSecret", + "tags": [ + "Webhooks" + ], + "responses": { + "200": { + "description": "WebhookSubscription resource created", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/WebhookSubscription.WebhookSecretOutput.jsonld-Webhook.read" + } + } + }, + "links": {} + }, + "400": { + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + } + }, + "summary": "Rotate webhook signing secret", + "description": "Generates a new signing secret, invalidating the previous one. Requires organization.webhooks.manage. The response includes the new PLAINTEXT secret exactly once.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "WebhookSubscription identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "webhookId", + "in": "path", + "description": "WebhookSubscription identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ] + } + }, + "/api/webhooks/event-types": { + "get": { + "operationId": "listWebhookEventTypes", + "tags": [ + "Webhooks" + ], + "responses": { + "200": { + "description": "WebhookEventType collection", + "content": { + "application/ld+json": { + "schema": { + "type": "object", + "description": "WebhookEventType.WebhookEventTypeOutput.jsonld-Webhook.read collection.", + "allOf": [ + { + "$ref": "#/components/schemas/HydraCollectionBaseSchemaNoPagination" + }, + { + "type": "object", + "required": [ + "member" + ], + "properties": { + "member": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WebhookEventType.WebhookEventTypeOutput.jsonld-Webhook.read" + } + } + } + } + ] + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + } + }, + "summary": "List webhook event types", + "description": "Returns the curated public event type allowlist a webhook subscription may register for.", + "parameters": [] + } + }, + "/api/organizations/{organizationId}/workload": { + "get": { + "operationId": "workload_get", + "tags": [ + "Workload" + ], + "responses": { + "200": { + "description": "Workload resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Workload.WorkloadOutput.jsonld" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "404": { + "description": "Not found", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + } + }, + "summary": "Retrieves a Workload resource.", + "description": "Retrieves a Workload resource.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "Workload identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "from", + "in": "query", + "description": "Workload from", + "required": true, + "deprecated": false, + "schema": { + "type": "string", + "format": "date" + }, + "style": "form", + "explode": true + }, + { + "name": "to", + "in": "query", + "description": "Workload to", + "required": true, + "deprecated": false, + "schema": { + "type": "string", + "format": "date" + }, + "style": "form", + "explode": true + }, + { + "name": "member", + "in": "query", + "description": "Workload member", + "required": false, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "form", + "explode": true + }, + { + "name": "team", + "in": "query", + "description": "Workload team", + "required": false, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "form", + "explode": true + }, + { + "name": "overloaded", + "in": "query", + "description": "Workload overloaded", + "required": false, + "deprecated": false, + "schema": { + "type": "boolean" + }, + "style": "form", + "explode": true + }, + { + "name": "page", + "in": "query", + "description": "One-based member page. Pagination never limits workload contributions.", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "minimum": 1, + "default": 1 + }, + "style": "form", + "explode": true + }, + { + "name": "pageSize", + "in": "query", + "description": "Members per page, after member, team and overload filters.", + "required": false, + "deprecated": false, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 10 + }, + "style": "form", + "explode": true + } + ] + } + }, + "/api/organizations/{organizationId}/workload/assessments": { + "post": { + "operationId": "workload_assess", + "tags": [ + "Workload" + ], + "responses": { + "200": { + "description": "Workload resource created", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Workload.WorkloadAssessmentOutput.jsonld" + } + } + }, + "links": {} + }, + "400": { + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + } + }, + "summary": "Creates a Workload resource.", + "description": "Creates a Workload resource.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "Workload identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], + "requestBody": { + "description": "The new Workload resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Workload.WorkloadAssessmentInput" + } + } + }, + "required": true + } + } + }, + "/api/organizations/{organizationId}/workload/members/{memberId}/capacity": { + "get": { + "operationId": "workload_capacity_get", + "tags": [ + "WorkloadCapacity" + ], + "responses": { + "200": { + "description": "WorkloadCapacity resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/WorkloadCapacity.CapacityOutput.jsonld" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "404": { + "description": "Not found", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + } + }, + "summary": "Retrieves a WorkloadCapacity resource.", + "description": "Retrieves a WorkloadCapacity resource.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "WorkloadCapacity identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "memberId", + "in": "path", + "description": "WorkloadCapacity identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ] + }, + "post": { + "operationId": "workload_capacity_save", + "tags": [ + "WorkloadCapacity" + ], + "responses": { + "201": { + "description": "WorkloadCapacity resource created", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/WorkloadCapacity.CapacityChangeOutput.jsonld" + } + } + }, + "links": {} + }, + "400": { + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + } + }, + "summary": "Creates a WorkloadCapacity resource.", + "description": "Creates a WorkloadCapacity resource.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "WorkloadCapacity identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "memberId", + "in": "path", + "description": "WorkloadCapacity identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], + "requestBody": { + "description": "The new WorkloadCapacity resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/WorkloadCapacity.CapacityWeekInput" + } + } + }, + "required": true + } + } + }, + "/api/organizations/{organizationId}/workload/members/{memberId}/exceptions": { + "get": { + "operationId": "workload_exceptions_get", + "tags": [ + "WorkloadCapacity" + ], + "responses": { + "200": { + "description": "WorkloadCapacity resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/WorkloadCapacity.CapacityOutput.jsonld" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "404": { + "description": "Not found", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + } + }, + "summary": "Retrieves a WorkloadCapacity resource.", + "description": "Retrieves a WorkloadCapacity resource.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "WorkloadCapacity identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "memberId", + "in": "path", + "description": "WorkloadCapacity identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ] + }, + "post": { + "operationId": "workload_exception_create", + "tags": [ + "WorkloadCapacity" + ], + "responses": { + "201": { + "description": "WorkloadCapacity resource created", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/WorkloadCapacity.CapacityChangeOutput.jsonld" + } + } + }, + "links": {} + }, + "400": { + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + } + }, + "summary": "Creates a WorkloadCapacity resource.", + "description": "Creates a WorkloadCapacity resource.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "WorkloadCapacity identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "memberId", + "in": "path", + "description": "WorkloadCapacity identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], + "requestBody": { + "description": "The new WorkloadCapacity resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/WorkloadCapacity.CapacityExceptionInput" + } + } + }, + "required": true + } + } + }, + "/api/organizations/{organizationId}/workload/members/{memberId}/exceptions/{exceptionId}": { + "delete": { + "operationId": "workload_exception_cancel", + "tags": [ + "WorkloadCapacity" + ], + "responses": { + "204": { + "description": "WorkloadCapacity resource deleted" + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "404": { + "description": "Not found", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + } + }, + "summary": "Removes the WorkloadCapacity resource.", + "description": "Removes the WorkloadCapacity resource.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "WorkloadCapacity identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "memberId", + "in": "path", + "description": "WorkloadCapacity identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + }, + { + "name": "exceptionId", + "in": "path", + "description": "WorkloadCapacity identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ] + } + }, + "/api/organizations/{organizationId}/workload/settings": { + "get": { + "operationId": "workload_settings_get", + "tags": [ + "WorkloadCapacity" + ], + "responses": { + "200": { + "description": "WorkloadCapacity resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/WorkloadCapacity.CapacityOutput.jsonld" + } + } + } + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "404": { + "description": "Not found", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + } + }, + "summary": "Retrieves a WorkloadCapacity resource.", + "description": "Retrieves a WorkloadCapacity resource.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "WorkloadCapacity identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ] + }, + "post": { + "operationId": "workload_settings_save", + "tags": [ + "WorkloadCapacity" + ], + "responses": { + "201": { + "description": "WorkloadCapacity resource created", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/WorkloadCapacity.CapacityChangeOutput.jsonld" + } + } + }, + "links": {} + }, + "400": { + "description": "Invalid input", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "403": { + "description": "Forbidden", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/Error.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "links": {} + }, + "422": { + "description": "An error occurred", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation.jsonld" + } + }, + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConstraintViolation" + } + } + }, + "links": {} + } + }, + "summary": "Creates a WorkloadCapacity resource.", + "description": "Creates a WorkloadCapacity resource.", + "parameters": [ + { + "name": "organizationId", + "in": "path", + "description": "WorkloadCapacity identifier", + "required": true, + "deprecated": false, + "schema": { + "type": "string" + }, + "style": "simple", + "explode": false + } + ], + "requestBody": { + "description": "The new WorkloadCapacity resource", + "content": { + "application/ld+json": { + "schema": { + "$ref": "#/components/schemas/WorkloadCapacity.CapacityWeekInput" + } + } + }, + "required": true + } + } + } + }, + "components": { + "schemas": { + "AddressSuggestionOutput.jsonld-facility.read": { + "type": "object", + "properties": { + "displayName": { + "description": "Constructor.", + "type": "string" + }, + "latitude": { + "description": "Constructor.", + "type": "number" + }, + "longitude": { + "description": "Constructor.", + "type": "number" + }, + "street": { + "description": "Constructor.", + "type": "string" + }, + "city": { + "description": "Constructor.", + "type": "string" + }, + "region": { + "description": "Constructor.", + "type": "string" + }, + "postalCode": { + "description": "Constructor.", + "type": "string" + }, + "country": { + "description": "Constructor.", + "type": "string" + }, + "countryCode": { + "description": "Constructor.", + "type": "string" + } + } + }, + "ApprovalActionType.ApprovalActionTypeOutput.jsonld-Approval.read": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { + "value": { + "readOnly": true, + "description": "Property value.", + "default": "", + "type": "string" + }, + "label": { + "readOnly": true, + "description": "Property label.", + "default": "", + "type": "string" + } + } + } + ], + "description": "Reference catalog of regulated action types the approval policy can gate." + }, + "ApprovalRequest.ApprovalRequestOutput.jsonld-Approval.read": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { + "id": { + "readOnly": true, + "description": "Property id.", + "default": "", + "type": "string" + }, + "organizationId": { + "readOnly": true, + "description": "Property organizationId.", + "default": "", + "type": "string" + }, + "actionType": { + "readOnly": true, + "description": "Property actionType.", + "default": "", + "type": "string" + }, + "subjectId": { + "readOnly": true, + "description": "Property subjectId.", + "default": "", + "type": "string" + }, + "status": { + "readOnly": true, + "description": "Property status.", + "default": "", + "type": "string" }, "requestedByMemberId": { "readOnly": true, - "description": "Property requestedByMemberId.", + "description": "Property requestedByMemberId.", + "default": "", + "type": "string" + }, + "requestedByUserId": { + "readOnly": true, + "description": "Property requestedByUserId.", + "default": "", + "type": "string" + }, + "decisionByMemberId": { + "readOnly": true, + "description": "Property decisionByMemberId.", + "type": [ + "string", + "null" + ] + }, + "decisionByUserId": { + "readOnly": true, + "description": "Property decisionByUserId.", + "type": [ + "string", + "null" + ] + }, + "decisionNote": { + "readOnly": true, + "description": "Property decisionNote.", + "type": [ + "string", + "null" + ] + }, + "expiresAt": { + "readOnly": true, + "description": "Property expiresAt.", + "default": "", + "type": "string" + }, + "createdAt": { + "readOnly": true, + "description": "Property createdAt.", + "default": "", + "type": "string" + }, + "updatedAt": { + "readOnly": true, + "description": "Property updatedAt.", + "default": "", + "type": "string" + }, + "decidedAt": { + "readOnly": true, + "description": "Property decidedAt.", + "type": [ + "string", + "null" + ] + }, + "executedAt": { + "readOnly": true, + "description": "Property executedAt.", + "type": [ + "string", + "null" + ] + }, + "executionError": { + "readOnly": true, + "description": "Property executionError.", + "type": [ + "string", + "null" + ] + }, + "allowedActions": { + "description": "available decisions, revalidated by the command", + "type": "array", + "items": { + "type": "string" + } + }, + "decisionBlockReason": { + "description": "Property decisionBlockReason", + "type": [ + "string", + "null" + ] + } + } + } + ], + "description": "Organization-scoped four-eyes approval request queue." + }, + "ApprovalRequest.ApproveApprovalRequestInput-Approval.write": { + "type": "object", + "description": "Organization-scoped four-eyes approval request queue.", + "properties": { + "decisionNote": { + "maxLength": 2000, + "description": "Optional free-form decision note", + "type": [ + "string", + "null" + ] + } + } + }, + "ApprovalRequest.RejectApprovalRequestInput-Approval.write": { + "type": "object", + "description": "Organization-scoped four-eyes approval request queue.", + "properties": { + "decisionNote": { + "maxLength": 2000, + "description": "Optional free-form decision note", + "type": [ + "string", + "null" + ] + } + } + }, + "ApprovalRequest.WithdrawApprovalRequestInput-Approval.write": { + "type": "object", + "description": "Organization-scoped four-eyes approval request queue.", + "properties": { + "decisionNote": { + "maxLength": 2000, + "description": "Optional withdrawal reason", + "type": [ + "string", + "null" + ] + } + } + }, + "AssistantMessageOutput.jsonld-Assistant.read": { + "type": "object", + "properties": { + "id": { + "readOnly": true, + "description": "Property id.", + "default": "", + "type": "string" + }, + "threadId": { + "readOnly": true, + "description": "Property threadId.", + "default": "", + "type": "string" + }, + "organizationId": { + "readOnly": true, + "description": "Property organizationId.", + "default": "", + "type": "string" + }, + "role": { + "readOnly": true, + "description": "Property role.", + "default": "", + "type": "string" + }, + "body": { + "readOnly": true, + "description": "Property body.", + "default": "", + "type": "string" + }, + "status": { + "readOnly": true, + "description": "Property status.", + "default": "", + "type": "string" + }, + "errorCode": { + "readOnly": true, + "description": "Property errorCode.", + "type": [ + "string", + "null" + ] + }, + "tokenCount": { + "readOnly": true, + "description": "Property tokenCount.", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "readOnly": true, + "description": "Property createdAt.", + "default": "", + "type": "string" + }, + "completedAt": { + "readOnly": true, + "description": "Property completedAt.", + "type": [ + "string", + "null" + ] + }, + "attemptId": { + "readOnly": true, + "description": "Property attemptId", + "type": [ + "string", + "null" + ] + }, + "attemptNumber": { + "readOnly": true, + "description": "Property attemptNumber", + "default": 0, + "type": "integer" + }, + "attemptSequence": { + "readOnly": true, + "description": "Property attemptSequence", + "default": 0, + "type": "integer" + }, + "attemptExpiresAt": { + "readOnly": true, + "description": "Property attemptExpiresAt", + "type": [ + "string", + "null" + ] + }, + "canCancel": { + "readOnly": true, + "description": "Property canCancel", + "default": false, + "type": "boolean" + }, + "canRetry": { + "readOnly": true, + "description": "Property canRetry", + "default": false, + "type": "boolean" + } + } + }, + "AssistantThread.AskAssistantQuestionInput-Assistant.write": { + "type": "object", + "description": "Member-private AI-assistant chat threads.", + "required": [ + "body" + ], + "properties": { + "body": { + "maxLength": 8000, + "description": "The question body", + "default": "", + "type": "string" + }, + "temperature": { + "minimum": 0, + "maximum": 2, + "description": "Optional sampling temperature for this question only (0.0-2.0)", + "type": [ + "number", + "null" + ] + } + } + }, + "AssistantThread.AskAssistantQuestionOutput.jsonld-Assistant.read": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { + "threadId": { + "readOnly": true, + "description": "Property threadId.", + "default": "", + "type": "string" + }, + "organizationId": { + "readOnly": true, + "description": "Property organizationId.", + "default": "", + "type": "string" + }, + "userMessage": { + "readOnly": true, + "description": "Property userMessage.", + "$ref": "#/components/schemas/AssistantMessageOutput.jsonld-Assistant.read" + }, + "assistantMessage": { + "readOnly": true, + "description": "Property assistantMessage.", + "$ref": "#/components/schemas/AssistantMessageOutput.jsonld-Assistant.read" + } + } + } + ], + "description": "Member-private AI-assistant chat threads." + }, + "AssistantThread.AssistantMessageOutput.jsonld-Assistant.read": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { + "id": { + "readOnly": true, + "description": "Property id.", + "default": "", + "type": "string" + }, + "threadId": { + "readOnly": true, + "description": "Property threadId.", + "default": "", + "type": "string" + }, + "organizationId": { + "readOnly": true, + "description": "Property organizationId.", + "default": "", + "type": "string" + }, + "role": { + "readOnly": true, + "description": "Property role.", + "default": "", + "type": "string" + }, + "body": { + "readOnly": true, + "description": "Property body.", + "default": "", + "type": "string" + }, + "status": { + "readOnly": true, + "description": "Property status.", + "default": "", + "type": "string" + }, + "errorCode": { + "readOnly": true, + "description": "Property errorCode.", + "type": [ + "string", + "null" + ] + }, + "tokenCount": { + "readOnly": true, + "description": "Property tokenCount.", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "readOnly": true, + "description": "Property createdAt.", + "default": "", + "type": "string" + }, + "completedAt": { + "readOnly": true, + "description": "Property completedAt.", + "type": [ + "string", + "null" + ] + }, + "attemptId": { + "readOnly": true, + "description": "Property attemptId", + "type": [ + "string", + "null" + ] + }, + "attemptNumber": { + "readOnly": true, + "description": "Property attemptNumber", + "default": 0, + "type": "integer" + }, + "attemptSequence": { + "readOnly": true, + "description": "Property attemptSequence", + "default": 0, + "type": "integer" + }, + "attemptExpiresAt": { + "readOnly": true, + "description": "Property attemptExpiresAt", + "type": [ + "string", + "null" + ] + }, + "canCancel": { + "readOnly": true, + "description": "Property canCancel", + "default": false, + "type": "boolean" + }, + "canRetry": { + "readOnly": true, + "description": "Property canRetry", + "default": false, + "type": "boolean" + } + } + } + ], + "description": "Member-private AI-assistant chat threads." + }, + "AssistantThread.AssistantThreadDetailOutput.jsonld-Assistant.read": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { + "id": { + "readOnly": true, + "description": "Property id.", + "default": "", + "type": "string" + }, + "organizationId": { + "readOnly": true, + "description": "Property organizationId.", + "default": "", + "type": "string" + }, + "memberId": { + "readOnly": true, + "description": "Property memberId.", + "default": "", + "type": "string" + }, + "title": { + "readOnly": true, + "description": "Property title.", + "type": [ + "string", + "null" + ] + }, + "model": { + "readOnly": true, + "description": "Property model.", + "type": [ + "string", + "null" + ] + }, + "createdAt": { + "readOnly": true, + "description": "Property createdAt.", + "default": "", + "type": "string" + }, + "updatedAt": { + "readOnly": true, + "description": "Property updatedAt.", + "default": "", + "type": "string" + }, + "lastMessageAt": { + "readOnly": true, + "description": "Property lastMessageAt.", + "type": [ + "string", + "null" + ] + }, + "messages": { + "readOnly": true, + "description": "Property messages.", + "type": "array", + "items": { + "$ref": "#/components/schemas/AssistantMessageOutput.jsonld-Assistant.read" + } + }, + "messagesPage": { + "readOnly": true, + "description": "Property messagesPage.", + "default": 1, + "type": "integer" + }, + "messagesItemsPerPage": { + "readOnly": true, + "description": "Property messagesItemsPerPage.", + "default": 50, + "type": "integer" + }, + "messagesTotal": { + "readOnly": true, + "description": "Property messagesTotal.", + "default": 0, + "type": "integer" + } + } + } + ], + "description": "Member-private AI-assistant chat threads." + }, + "AssistantThread.AssistantThreadOutput.jsonld-Assistant.read": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { + "id": { + "readOnly": true, + "description": "Property id.", + "default": "", + "type": "string" + }, + "organizationId": { + "readOnly": true, + "description": "Property organizationId.", + "default": "", + "type": "string" + }, + "memberId": { + "readOnly": true, + "description": "Property memberId.", + "default": "", + "type": "string" + }, + "title": { + "readOnly": true, + "description": "Property title.", + "type": [ + "string", + "null" + ] + }, + "model": { + "readOnly": true, + "description": "Property model.", + "type": [ + "string", + "null" + ] + }, + "createdAt": { + "readOnly": true, + "description": "Property createdAt.", + "default": "", + "type": "string" + }, + "updatedAt": { + "readOnly": true, + "description": "Property updatedAt.", + "default": "", + "type": "string" + }, + "lastMessageAt": { + "readOnly": true, + "description": "Property lastMessageAt.", + "type": [ + "string", + "null" + ] + } + } + } + ], + "description": "Member-private AI-assistant chat threads." + }, + "AssistantThread.AssistantThreadSubscriptionOutput.jsonld-Assistant.read": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { + "token": { + "readOnly": true, + "description": "Property token.", + "default": "", + "type": "string" + }, + "topic": { + "readOnly": true, + "description": "Property topic.", + "default": "", + "type": "string" + } + } + } + ], + "description": "Member-private AI-assistant chat threads." + }, + "AssistantThread.ControlAssistantAttemptInput-Assistant.write": { + "type": "object", + "description": "Member-private AI-assistant chat threads.", + "required": [ + "attemptId" + ], + "properties": { + "attemptId": { + "format": "uuid", + "description": "Property attemptId", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "default": "", + "type": "string" + } + } + }, + "AssistantThread.StartAssistantThreadInput-Assistant.write": { + "type": "object", + "description": "Member-private AI-assistant chat threads.", + "properties": { + "title": { + "maxLength": 255, + "description": "Optional display title for the thread", + "type": [ + "string", + "null" + ] + }, + "model": { + "maxLength": 255, + "description": "Optional tenant-selected model override, validated against the operator-configured allowlist", + "type": [ + "string", + "null" + ] + } + } + }, + "AuditEvent.AuditEventOutput.jsonld-audit.read": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "required": [ + "id", + "action", + "actorType", + "occurredAt", + "recordedAt", + "chainId", + "sequence", + "eventHash" + ], + "properties": { + "id": { + "readOnly": true, + "description": "Audit event identifier (UUID).", + "default": "", + "example": "550e8400-e29b-41d4-a716-446655440000", + "type": "string", + "format": "uuid" + }, + "action": { + "readOnly": true, + "description": "Audit action key.", + "default": "", + "example": "auth.login_success", + "type": "string" + }, + "actorType": { + "readOnly": true, + "description": "Actor type (user, client, system, anonymous).", + "default": "", + "example": "user", + "type": "string" + }, + "actorId": { + "readOnly": true, + "description": "Actor identifier (UUID or external ID).", + "example": "0c1b3f6a-8c2e-4f5d-9c0a-2c2b7b9f1c7a", + "type": "string", + "nullable": true + }, + "actorEmail": { + "readOnly": true, + "description": "Actor email (optional, depending on PII settings).", + "example": "user@example.com", + "type": "string", + "nullable": true + }, + "actorEmailHash": { + "readOnly": true, + "description": "Actor email hash (SHA-256).", + "example": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "type": "string", + "nullable": true + }, + "subjectType": { + "readOnly": true, + "description": "Subject type (token, user, client, etc.).", + "example": "token", + "type": "string", + "nullable": true + }, + "subjectId": { + "readOnly": true, + "description": "Subject identifier (UUID or token ID).", + "example": "f4d3b0f0-2b4c-4cdd-9d5a-2d2fd9b3c1a1", + "type": "string", + "nullable": true + }, + "clientId": { + "readOnly": true, + "description": "Client identifier (UUID).", + "example": "3e9d2c1a-72d4-4b18-9d4d-9a1234567890", + "type": "string", + "nullable": true + }, + "tenantId": { + "readOnly": true, + "description": "Tenant identifier (UUID).", + "example": "00000000-0000-4000-8000-000000000001", + "type": "string", + "nullable": true + }, + "ipAddress": { + "readOnly": true, + "description": "Client IP address (optional, depending on PII settings).", + "example": "203.0.113.5", + "type": "string", + "nullable": true + }, + "ipHash": { + "readOnly": true, + "description": "IP hash (SHA-256).", + "example": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "type": "string", + "nullable": true + }, + "userAgent": { + "readOnly": true, + "description": "User agent string (if available).", + "example": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36", + "type": "string", + "nullable": true + }, + "metadata": { + "readOnly": true, + "description": "Event metadata payload (JSON).", + "example": { + "ip": "203.0.113.5", + "action": "login", + "success": true + }, + "type": "object", + "additionalProperties": true + }, + "occurredAt": { + "readOnly": true, + "description": "ISO 8601 datetime when the event occurred.", + "default": "", + "example": "2026-01-30T09:45:00+00:00", + "type": "string", + "format": "date-time" + }, + "recordedAt": { + "readOnly": true, + "description": "ISO 8601 datetime when the event was recorded.", + "default": "", + "example": "2026-01-30T09:45:01+00:00", + "type": "string", + "format": "date-time" + }, + "chainId": { + "readOnly": true, + "description": "Audit chain identifier.", + "default": "", + "example": "tenant:00000000-0000-4000-8000-000000000001", + "type": "string" + }, + "sequence": { + "readOnly": true, + "description": "Monotonic sequence number within the chain.", + "default": 0, + "example": 123, + "type": "integer" + }, + "prevHash": { + "readOnly": true, + "description": "Previous event hash in the chain.", + "example": "cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce", + "type": "string", + "nullable": true + }, + "eventHash": { + "readOnly": true, + "description": "Hash of the event payload (SHA-256).", + "default": "", + "example": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "type": "string" + } + } + } + ], + "description": "Returns a paginated list of audit events." + }, + "Auth.LoginInput-token.write": { + "type": "object", + "description": "User authentication operations", + "required": [ + "email", + "password" + ], + "properties": { + "email": { + "format": "email", + "writeOnly": true, + "description": "Email address of the user attempting to authenticate", + "externalDocs": { + "url": "https://schema.org/email" + }, + "example": "john.doe@example.com", + "type": "string", + "minLength": 5, + "maxLength": 255, + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$" + }, + "password": { + "minLength": 8, + "maxLength": 128, + "writeOnly": true, + "description": "Password of the user (write-only, never returned in responses)", + "example": "SecureP@ssw0rd!", + "type": "string", + "format": "password" + }, + "remember_me": { + "writeOnly": true, + "description": "Extend session duration (30 days if true, default 1 day if false)", + "default": false, + "example": false, + "type": "boolean", + "nullable": false + } + } + }, + "Auth.LoginOutput.jsonld-token.read": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { + "access_token": { + "readOnly": true, + "description": "JWT access token for API authentication; absent while MFA is required", + "example": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...", + "type": "string" + }, + "token_type": { + "readOnly": true, + "description": "Token type; absent while MFA is required", + "example": "Bearer", + "type": "string", + "enum": [ + "Bearer" + ], + "default": "Bearer" + }, + "expires_in": { + "readOnly": true, + "description": "Token lifetime in seconds; absent while MFA is required", + "example": 3600, + "type": "integer", + "minimum": 1 + }, + "scope": { + "readOnly": true, + "description": "Space-separated list of granted scopes", + "example": "openid profile email", + "type": "string", + "nullable": true + }, + "mfa_required": { + "readOnly": true, + "description": "If true, authentication is incomplete. Use mfa_token to verify code.", + "example": true, + "type": "boolean", + "nullable": true + }, + "mfa_token": { + "readOnly": true, + "description": "Temporary Pre-Auth Token (JWT) covering the partial authentication state.", + "example": "eyJ...", + "type": "string", + "nullable": true + }, + "challenge_token": { + "readOnly": true, + "description": "The OTP challenge token (reference).", + "example": "abc...", + "type": "string", + "nullable": true + }, + "mfa_method": { + "readOnly": true, + "description": "The delivery method for the MFA code (email, sms, totp).", + "example": "email", + "type": "string", + "enum": [ + "email", + "sms", + "totp" + ], + "nullable": true + }, + "mfa_destination": { + "readOnly": true, + "description": "The masked destination where the code was sent (e.g., \"j***e@e****e.com\" or \"+336****5678\").", + "example": "j***e@e****e.com", + "type": "string", + "nullable": true + }, + "mfa_resend_in": { + "readOnly": true, + "description": "Seconds until MFA code can be resent", + "example": 60, + "type": "integer", + "nullable": true + }, + "return_url": { + "description": "Validated local destination returned by a federated login.", + "type": [ + "string", + "null" + ] + }, + "new_account": { + "description": "Whether a federated login provisioned the Fireguard account.", + "type": [ + "boolean", + "null" + ] + } + } + } + ], + "description": "User authentication operations" + }, + "Auth.LogoutOutput.jsonld-token.read": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "required": [ + "message" + ], + "properties": { + "message": { + "readOnly": true, + "description": "Confirmation message indicating successful logout", + "default": "Logged out successfully", + "example": "Logged out successfully", + "type": "string" + } + } + } + ], + "description": "User authentication operations" + }, + "Auth.MfaResendInput": { + "type": "object", + "description": "User authentication operations", + "required": [ + "preAuthToken" + ], + "properties": { + "preAuthToken": { + "writeOnly": true, + "description": "The Pre-Auth Token received from the login response.", + "example": "eyJ...", + "type": "string" + } + } + }, + "Auth.MfaVerifyInput": { + "type": "object", + "description": "User authentication operations", + "required": [ + "preAuthToken", + "code" + ], + "properties": { + "preAuthToken": { + "writeOnly": true, + "description": "The Pre-Auth Token received from the login response.", + "example": "eyJ...", + "type": "string" + }, + "code": { + "writeOnly": true, + "description": "The OTP code received by the user.", + "example": "123456", + "type": "string" + } + } + }, + "AutomationAttempt.AutomationAttemptOutput.jsonld": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { + "id": { + "description": "Method __construct", + "type": "string" + }, + "runId": { + "description": "Method __construct", + "type": "string" + }, + "organizationId": { + "description": "Method __construct", + "type": "string" + }, + "ruleKey": { + "description": "Method __construct", + "type": "string" + }, + "subjectId": { + "description": "Method __construct", + "type": "string" + }, + "attemptNumber": { + "description": "Method __construct", + "type": "integer" + }, + "status": { + "description": "Method __construct", + "type": "string" + }, + "createdAt": { + "description": "Method __construct", + "type": "string" + }, + "finishedAt": { + "description": "Method __construct", + "type": [ + "string", + "null" + ] + }, + "requestedBy": { + "description": "Method __construct", + "type": [ + "string", + "null" + ] + }, + "interventionId": { + "description": "Method __construct", + "type": [ + "string", + "null" + ] + }, + "errorCode": { + "description": "Method __construct", + "type": [ + "string", + "null" + ] + }, + "canRetry": { + "description": "Method __construct", + "type": "boolean" + } + } + } + ], + "description": "Resource AutomationResource. Separate organization history and management access." + }, + "AutomationAttempt.AutomationPolicyOutput.jsonld": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { + "id": { + "description": "Method __construct", + "type": "string" + }, + "ruleKey": { + "description": "Method __construct", + "type": "string" + }, + "enabled": { + "description": "Method __construct", + "type": "boolean" + }, + "canManage": { + "description": "Method __construct", + "type": "boolean" + } + } + } + ], + "description": "Resource AutomationResource. Separate organization history and management access." + }, + "AutomationAttempt.RetryAutomationAttemptInput": { + "type": "object", + "description": "Resource AutomationResource. Separate organization history and management access.", + "required": [ + "attemptId" + ], + "properties": { + "attemptId": { + "format": "uuid", + "description": "Property attemptId", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "default": "", + "type": "string" + } + } + }, + "BillingPricing.PlanPricingOutput.jsonld-Billing.read": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { + "planKey": { + "readOnly": true, + "description": "Property planKey.", + "default": "", + "type": "string" + }, + "currency": { + "readOnly": true, + "description": "Property currency.", + "default": "", + "type": "string" + }, + "monthlyAmount": { + "readOnly": true, + "description": "Property monthlyAmount.", + "type": [ + "integer", + "null" + ] + }, + "yearlyAmount": { + "readOnly": true, + "description": "Property yearlyAmount.", + "type": [ + "integer", + "null" + ] + } + } + } + ], + "description": "Subscription plan pricing." + }, + "CalendarEvent.CalendarEventOutput.jsonld-Calendar.read": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { + "id": { + "readOnly": true, + "description": "Property id.", + "default": "", + "type": "string" + }, + "organizationId": { + "readOnly": true, + "description": "Property organizationId.", + "default": "", + "type": "string" + }, + "title": { + "readOnly": true, + "description": "Property title.", + "default": "", + "type": "string" + }, + "description": { + "readOnly": true, + "description": "Property description.", + "type": [ + "string", + "null" + ] + }, + "startsAt": { + "readOnly": true, + "description": "Property startsAt.", + "default": "", + "type": "string" + }, + "endsAt": { + "readOnly": true, + "description": "Property endsAt.", + "type": [ + "string", + "null" + ] + }, + "allDay": { + "readOnly": true, + "description": "Property allDay.", + "default": false, + "type": "boolean" + }, + "facilityId": { + "readOnly": true, + "description": "Property facilityId.", + "type": [ + "string", + "null" + ] + }, + "createdByMemberId": { + "readOnly": true, + "description": "Property createdByMemberId.", + "default": "", + "type": "string" + }, + "createdAt": { + "readOnly": true, + "description": "Property createdAt.", + "default": "", + "type": "string" + }, + "updatedAt": { + "readOnly": true, + "description": "Property updatedAt.", + "default": "", + "type": "string" + } + } + } + ], + "description": "Organization-scoped standalone calendar event management." + }, + "CalendarEvent.CreateCalendarEventInput-Calendar.write": { + "type": "object", + "description": "Organization-scoped standalone calendar event management.", + "required": [ + "title", + "startsAt" + ], + "properties": { + "title": { + "maxLength": 255, + "description": "Event title", + "default": "", + "example": "Fire drill", + "type": "string" + }, + "description": { + "maxLength": 5000, + "description": "Free-form description", + "type": [ + "string", + "null" + ] + }, + "startsAt": { + "description": "Event start, ISO 8601 with an explicit timezone offset", + "externalDocs": { + "url": "https://schema.org/DateTime" + }, + "default": "", + "example": "2026-08-01T09:00:00+02:00", + "type": "string" + }, + "endsAt": { + "description": "Event end, ISO 8601 with an explicit timezone offset", + "externalDocs": { + "url": "https://schema.org/DateTime" + }, + "example": "2026-08-01T11:00:00+02:00", + "type": [ + "string", + "null" + ] + }, + "allDay": { + "description": "Whether the event spans whole day(s)", + "default": false, + "type": "boolean" + }, + "facilityId": { + "description": "Associated facility identifier", + "type": [ + "string", + "null" + ] + } + } + }, + "CalendarEvent.UpdateCalendarEventInput-Calendar.write.jsonMergePatch": { + "type": "object", + "description": "Organization-scoped standalone calendar event management.", + "properties": { + "title": { + "minLength": 1, + "maxLength": 255, + "description": "Event title", + "type": [ + "string", + "null" + ] + }, + "description": { + "maxLength": 5000, + "description": "Free-form description", + "type": [ + "string", + "null" + ] + }, + "startsAt": { + "description": "Event start, ISO 8601 with an explicit timezone offset", + "externalDocs": { + "url": "https://schema.org/DateTime" + }, + "type": [ + "string", + "null" + ] + }, + "endsAt": { + "description": "Event end, ISO 8601 with an explicit timezone offset", + "externalDocs": { + "url": "https://schema.org/DateTime" + }, + "type": [ + "string", + "null" + ] + }, + "allDay": { + "description": "Whether the event spans whole day(s)", + "type": [ + "boolean", + "null" + ] + }, + "facilityId": { + "description": "Associated facility identifier", + "type": [ + "string", + "null" + ] + } + } + }, + "CalendarFeed.CalendarFeedOutput.jsonld-Calendar.read": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { + "from": { + "readOnly": true, + "description": "Property from.", + "default": "", + "type": "string" + }, + "to": { + "readOnly": true, + "description": "Property to.", + "default": "", + "type": "string" + }, + "items": { + "readOnly": true, + "description": "Property items.", + "type": "array", + "items": { + "$ref": "#/components/schemas/CalendarFeedItemOutput.jsonld-Calendar.read" + } + }, + "sources": { + "readOnly": true, + "type": "array", + "items": { + "$ref": "#/components/schemas/CalendarFeedSourceOutput.jsonld-Calendar.read" + } + }, + "complete": { + "readOnly": true, + "description": "Property complete", + "default": true, + "type": "boolean" + } + } + } + ], + "description": "Unified organization calendar feed (standalone events, inspections, interventions, preventive-maintenance due dates)." + }, + "CalendarFeedItemOutput.jsonld-Calendar.read": { + "type": "object", + "properties": { + "sourceKey": { + "readOnly": true, + "description": "Property sourceKey.", + "default": "", + "type": "string" + }, + "id": { + "readOnly": true, + "description": "Property id.", + "default": "", + "type": "string" + }, + "title": { + "readOnly": true, + "description": "Property title.", + "default": "", + "type": "string" + }, + "description": { + "readOnly": true, + "description": "Property description.", + "type": [ + "string", + "null" + ] + }, + "startsAt": { + "readOnly": true, + "description": "Property startsAt.", + "default": "", + "type": "string" + }, + "endsAt": { + "readOnly": true, + "description": "Property endsAt.", + "type": [ + "string", + "null" + ] + }, + "allDay": { + "readOnly": true, + "description": "Property allDay.", + "default": false, + "type": "boolean" + }, + "facilityId": { + "readOnly": true, + "description": "Property facilityId.", + "type": [ + "string", + "null" + ] + }, + "status": { + "readOnly": true, + "description": "Property status.", + "type": [ + "string", + "null" + ] + }, + "targetType": { + "readOnly": true, + "description": "Property targetType.", + "default": "", + "type": "string" + }, + "targetId": { + "readOnly": true, + "description": "Property targetId.", + "default": "", + "type": "string" + } + } + }, + "CalendarFeedSourceOutput.jsonld-Calendar.read": { + "type": "object", + "properties": { + "sourceKey": { + "description": "Property sourceKey", + "default": "", + "type": "string" + }, + "available": { + "description": "Property available", + "default": true, + "type": "boolean" + }, + "truncated": { + "description": "Property truncated", + "default": false, + "type": "boolean" + } + } + }, + "CalendarFeedToken.CalendarFeedTokenOutput.jsonld-Calendar.read": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { + "createdAt": { + "readOnly": true, + "description": "Property createdAt.", + "default": "", + "type": "string" + }, + "lastUsedAt": { + "readOnly": true, + "description": "Property lastUsedAt.", + "type": [ + "string", + "null" + ] + } + } + } + ], + "description": "Member-scoped iCal calendar feed subscription token." + }, + "CalendarFeedToken.CalendarFeedTokenSecretOutput.jsonld-Calendar.read": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { + "secret": { + "readOnly": true, + "description": "Property secret.", + "default": "", + "type": "string" + }, + "feedUrl": { + "readOnly": true, + "description": "Property feedUrl.", + "default": "", + "type": "string" + }, + "createdAt": { + "readOnly": true, + "description": "Property createdAt.", + "default": "", + "type": "string" + }, + "rotated": { + "readOnly": true, + "description": "Property rotated.", + "default": false, + "type": "boolean" + } + } + } + ], + "description": "Member-scoped iCal calendar feed subscription token." + }, + "CapacityConfigurationView.jsonld": { + "type": "object", + "properties": { + "weeks": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CapacityWeekView.jsonld" + } + }, + "exceptions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CapacityExceptionView.jsonld" + } + } + } + }, + "CapacityExceptionView.jsonld": { + "type": "object", + "properties": { + "id": { + "description": "stable resource identifier, retained across idempotent retries", + "type": "string" + }, + "memberId": { + "description": "organization member whose work or capacity is represented", + "type": "string" + }, + "startsOn": { + "description": "inclusive organization-local start date", + "type": "string" + }, + "endsOn": { + "description": "inclusive organization-local end date", + "type": "string" + }, + "minutes": { + "description": "duration in whole minutes", + "type": "integer" + } + } + }, + "CapacityWeekView.jsonld": { + "type": "object", + "properties": { + "id": { + "description": "stable resource identifier, retained across idempotent retries", + "type": "string" + }, + "scopeId": { + "description": "organization identifier for defaults, or member identifier for an individual override", + "type": "string" + }, + "effectiveOn": { + "description": "first local date on which this capacity version applies", + "type": "string" + }, + "minutes": { + "type": "array", + "items": { + "type": "integer" + } + } + } + }, + "Challenge.ChallengeOutput.jsonld": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "required": [ + "token", + "purpose", + "channel", + "maskedRecipient", + "status", + "expiresIn", + "attemptsRemaining", + "canResendIn" + ], + "properties": { + "token": { + "readOnly": true, + "description": "Challenge token to use in GET/verify/resend endpoints", + "example": "a1b2c3d4e5f6...", + "type": "string" + }, + "purpose": { + "readOnly": true, + "description": "The purpose of this OTP challenge", + "example": "login", + "type": "string" + }, + "channel": { + "readOnly": true, + "description": "The delivery channel used", + "example": "email", + "type": "string" + }, + "maskedRecipient": { + "readOnly": true, + "description": "Masked recipient for user-friendly display", + "example": "jo***@example.com", + "type": "string" + }, + "status": { + "readOnly": true, + "description": "Current status: pending, verified, expired, or failed", + "example": "pending", + "type": "string", + "enum": [ + "pending", + "verified", + "expired", + "failed" + ] + }, + "expiresIn": { + "readOnly": true, + "description": "Seconds remaining until the challenge expires", + "example": 285, + "type": "integer" + }, + "attemptsRemaining": { + "readOnly": true, + "description": "Number of verification attempts remaining", + "example": 4, + "type": "integer" + }, + "canResendIn": { + "readOnly": true, + "description": "Seconds until OTP can be resent (0 = can resend now)", + "example": 45, + "type": "integer" + } + } + } + ], + "description": "OTP challenge management for verification and 2FA." + }, + "Challenge.CreateChallengeInput": { + "type": "object", + "description": "OTP challenge management for verification and 2FA.", + "required": [ + "purpose", + "channel" + ], + "properties": { + "purpose": { + "enum": [ + "login", + "password_reset", + "email_verification", + "phone_verification", + "sensitive_operation", + "transaction_approval" + ], + "writeOnly": true, + "description": "The purpose of the OTP challenge. Use GET /api/otp/purposes to list available values.", + "example": "login", + "type": "string" + }, + "channel": { + "enum": [ + "email", + "sms", + "totp" + ], + "writeOnly": true, + "description": "The delivery channel for the OTP. Use GET /api/otp/channels to list available values.", + "example": "email", + "type": "string" + }, + "recipient": { + "writeOnly": true, + "description": "The recipient email or phone number. If not provided, user's default will be used.", + "example": "user@example.com", + "type": "string", + "nullable": true + }, + "ttlSeconds": { + "minimum": 60, + "maximum": 3600, + "writeOnly": true, + "description": "Custom time-to-live in seconds. Defaults to purpose-specific value.", + "example": 300, + "type": "integer", + "nullable": true + }, + "context": { + "writeOnly": true, + "description": "Optional context data (transaction ID, description, etc.)", + "example": { + "transactionId": "TXN-12345", + "description": "Confirm transfer of $100" + }, + "type": "object", + "nullable": true, + "additionalProperties": true + } + } + }, + "Challenge.VerifyOtpInput": { + "type": "object", + "description": "OTP challenge management for verification and 2FA.", + "required": [ + "code" + ], + "properties": { + "code": { + "writeOnly": true, + "description": "The OTP code to verify.", + "example": "123456", + "type": "string" + } + } + }, + "Challenge.VerifyOtpOutput.jsonld": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "required": [ + "success", + "attemptsRemaining" + ], + "properties": { + "success": { + "readOnly": true, + "description": "Whether verification was successful", + "example": true, + "type": "boolean" + }, + "attemptsRemaining": { + "readOnly": true, + "description": "Remaining verification attempts before the challenge is locked", + "example": 2, + "type": "integer" + }, + "error": { + "readOnly": true, + "description": "Error message when verification fails", + "example": "Invalid OTP code", + "type": "string", + "nullable": true + } + } + } + ], + "description": "OTP challenge management for verification and 2FA." + }, + "Channel.AddChannelParticipantInput": { + "type": "object", + "description": "Resource ChannelResource.\n\nNamed group channels — a `Conversation` with `subjectType=CHANNEL`,\n`subjectId=null`, `visibility=PARTICIPANTS`. Every operation requires\n`ROLE_USER` at the resource level; the finer-grained\n`organization.messaging.{read,write,manage}` + participant-membership\nchecks are enforced in the application layer (mirrors\n`ConversationResource`). Messages/subscription/read/edit/delete reuse the\nEXISTING `/api/conversations/{id}/...` endpoints unchanged — a channel id\nIS a conversation id. `PATCH /channels/{id}/parent` (L2.6) sets or clears\na channel's parent, nesting it under another channel — see\n`MessagingChannelHierarchyGuard` for the cycle/cross-organization/\nmax-depth invariants enforced before the write.", + "required": [ + "memberId" + ], + "properties": { + "memberId": { + "format": "uuid", + "description": "Property memberId.", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "default": "", + "type": "string" + }, + "role": { + "maxLength": 50, + "description": "Property role.", + "type": [ + "string", + "null" + ] + } + } + }, + "Channel.BindChannelTeamInput.jsonMergePatch": { + "type": "object", + "description": "Resource ChannelResource.\n\nNamed group channels — a `Conversation` with `subjectType=CHANNEL`,\n`subjectId=null`, `visibility=PARTICIPANTS`. Every operation requires\n`ROLE_USER` at the resource level; the finer-grained\n`organization.messaging.{read,write,manage}` + participant-membership\nchecks are enforced in the application layer (mirrors\n`ConversationResource`). Messages/subscription/read/edit/delete reuse the\nEXISTING `/api/conversations/{id}/...` endpoints unchanged — a channel id\nIS a conversation id. `PATCH /channels/{id}/parent` (L2.6) sets or clears\na channel's parent, nesting it under another channel — see\n`MessagingChannelHierarchyGuard` for the cycle/cross-organization/\nmax-depth invariants enforced before the write.", + "properties": { + "teamId": { + "format": "uuid", + "description": "Property teamId.", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "type": [ + "string", + "null" + ] + } + } + }, + "Channel.ChannelOutput.jsonld": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { + "id": { + "description": "Property id.", + "default": "", + "type": "string" + }, + "organization": { + "description": "Property organization.", + "default": "", + "type": "string" + }, + "name": { + "description": "Property name.", + "default": "", + "type": "string" + }, + "team": { + "description": "Property team.", + "type": [ + "string", + "null" + ] + }, + "createdByMember": { + "description": "Property createdByMember.", + "type": [ + "string", + "null" + ] + }, + "participantCount": { + "description": "Property participantCount.", + "default": 0, + "type": "integer" + }, + "isArchived": { + "description": "Property isArchived.", + "default": false, + "type": "boolean" + }, + "lastMessageAt": { + "description": "Property lastMessageAt.", + "type": [ + "string", + "null" + ] + }, + "messagesCount": { + "description": "Property messagesCount.", + "default": 0, + "type": "integer" + }, + "unreadCount": { + "description": "Property unreadCount.", + "default": 0, + "type": "integer" + }, + "createdAt": { + "description": "Property createdAt.", + "default": "", + "type": "string" + }, + "updatedAt": { + "description": "Property updatedAt.", + "default": "", + "type": "string" + }, + "isFavorite": { + "description": "Property isFavorite.", + "default": false, + "type": "boolean" + }, + "parent": { + "description": "Property parent.", + "type": [ + "string", + "null" + ] + } + } + } + ], + "description": "Resource ChannelResource.\n\nNamed group channels — a `Conversation` with `subjectType=CHANNEL`,\n`subjectId=null`, `visibility=PARTICIPANTS`. Every operation requires\n`ROLE_USER` at the resource level; the finer-grained\n`organization.messaging.{read,write,manage}` + participant-membership\nchecks are enforced in the application layer (mirrors\n`ConversationResource`). Messages/subscription/read/edit/delete reuse the\nEXISTING `/api/conversations/{id}/...` endpoints unchanged — a channel id\nIS a conversation id. `PATCH /channels/{id}/parent` (L2.6) sets or clears\na channel's parent, nesting it under another channel — see\n`MessagingChannelHierarchyGuard` for the cycle/cross-organization/\nmax-depth invariants enforced before the write." + }, + "Channel.ChannelParticipantOutput.jsonld": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { + "memberId": { + "description": "Property memberId.", + "default": "", + "type": "string" + }, + "role": { + "description": "Property role.", + "type": [ + "string", + "null" + ] + }, + "source": { + "description": "Property source.", + "default": "", + "type": "string" + }, + "addedAt": { + "description": "Property addedAt.", + "default": "", + "type": "string" + } + } + } + ], + "description": "Resource ChannelResource.\n\nNamed group channels — a `Conversation` with `subjectType=CHANNEL`,\n`subjectId=null`, `visibility=PARTICIPANTS`. Every operation requires\n`ROLE_USER` at the resource level; the finer-grained\n`organization.messaging.{read,write,manage}` + participant-membership\nchecks are enforced in the application layer (mirrors\n`ConversationResource`). Messages/subscription/read/edit/delete reuse the\nEXISTING `/api/conversations/{id}/...` endpoints unchanged — a channel id\nIS a conversation id. `PATCH /channels/{id}/parent` (L2.6) sets or clears\na channel's parent, nesting it under another channel — see\n`MessagingChannelHierarchyGuard` for the cycle/cross-organization/\nmax-depth invariants enforced before the write." + }, + "Channel.CreateChannelInput": { + "type": "object", + "description": "Resource ChannelResource.\n\nNamed group channels — a `Conversation` with `subjectType=CHANNEL`,\n`subjectId=null`, `visibility=PARTICIPANTS`. Every operation requires\n`ROLE_USER` at the resource level; the finer-grained\n`organization.messaging.{read,write,manage}` + participant-membership\nchecks are enforced in the application layer (mirrors\n`ConversationResource`). Messages/subscription/read/edit/delete reuse the\nEXISTING `/api/conversations/{id}/...` endpoints unchanged — a channel id\nIS a conversation id. `PATCH /channels/{id}/parent` (L2.6) sets or clears\na channel's parent, nesting it under another channel — see\n`MessagingChannelHierarchyGuard` for the cycle/cross-organization/\nmax-depth invariants enforced before the write.", + "required": [ + "organization", + "name" + ], + "properties": { + "organization": { + "description": "Property organization.", + "default": "", + "type": "string" + }, + "name": { + "minLength": 2, + "maxLength": 80, + "description": "Property name.", + "default": "", + "type": "string" + } + } + }, + "Channel.SetChannelParentInput.jsonMergePatch": { + "type": "object", + "description": "Resource ChannelResource.\n\nNamed group channels — a `Conversation` with `subjectType=CHANNEL`,\n`subjectId=null`, `visibility=PARTICIPANTS`. Every operation requires\n`ROLE_USER` at the resource level; the finer-grained\n`organization.messaging.{read,write,manage}` + participant-membership\nchecks are enforced in the application layer (mirrors\n`ConversationResource`). Messages/subscription/read/edit/delete reuse the\nEXISTING `/api/conversations/{id}/...` endpoints unchanged — a channel id\nIS a conversation id. `PATCH /channels/{id}/parent` (L2.6) sets or clears\na channel's parent, nesting it under another channel — see\n`MessagingChannelHierarchyGuard` for the cycle/cross-organization/\nmax-depth invariants enforced before the write.", + "properties": { + "parentChannelId": { + "format": "uuid", + "description": "Property parentChannelId.", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "type": [ + "string", + "null" + ] + } + } + }, + "Channel.UpdateChannelInput.jsonMergePatch": { + "type": "object", + "description": "Resource ChannelResource.\n\nNamed group channels — a `Conversation` with `subjectType=CHANNEL`,\n`subjectId=null`, `visibility=PARTICIPANTS`. Every operation requires\n`ROLE_USER` at the resource level; the finer-grained\n`organization.messaging.{read,write,manage}` + participant-membership\nchecks are enforced in the application layer (mirrors\n`ConversationResource`). Messages/subscription/read/edit/delete reuse the\nEXISTING `/api/conversations/{id}/...` endpoints unchanged — a channel id\nIS a conversation id. `PATCH /channels/{id}/parent` (L2.6) sets or clears\na channel's parent, nesting it under another channel — see\n`MessagingChannelHierarchyGuard` for the cycle/cross-organization/\nmax-depth invariants enforced before the write.", + "properties": { + "name": { + "minLength": 2, + "maxLength": 80, + "description": "Property name.", + "type": [ + "string", + "null" + ] + }, + "isArchived": { + "description": "Property isArchived.", + "type": [ + "boolean", + "null" + ] + } + } + }, + "Checklist.ChecklistOutput.jsonld-inspection.read": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { + "id": { + "readOnly": true, + "description": "Property id", + "default": "", + "type": "string" + }, + "organizationId": { + "readOnly": true, + "description": "Property organizationId", + "default": "", + "type": "string" + }, + "name": { + "readOnly": true, + "description": "Property name", + "default": "", + "type": "string" + }, + "referenceCode": { + "readOnly": true, + "description": "Property referenceCode", + "type": [ + "string", + "null" + ] + }, + "version": { + "readOnly": true, + "description": "Property version", + "default": "", + "type": "string" + }, + "status": { + "readOnly": true, + "description": "Property status", + "default": "", + "type": "string" + }, + "itemCount": { + "readOnly": true, + "description": "Property itemCount", + "default": 0, + "type": "integer" + }, + "items": { + "readOnly": true, + "description": "Property items", + "type": "array", + "items": { + "$ref": "#/components/schemas/ChecklistItemOutput.jsonld-inspection.read" + } + }, + "createdAt": { + "readOnly": true, + "description": "Property createdAt", + "default": "", + "type": "string" + }, + "updatedAt": { + "readOnly": true, + "description": "Property updatedAt", + "default": "", + "type": "string" + }, + "previousChecklistId": { + "description": "Property previousChecklistId", + "type": [ + "string", + "null" + ] + }, + "canEditMetadata": { + "description": "Property canEditMetadata", + "default": false, + "type": "boolean" + }, + "canEditItems": { + "description": "Property canEditItems", + "default": false, + "type": "boolean" + }, + "canCreateRevision": { + "description": "Property canCreateRevision", + "default": false, + "type": "boolean" + } + } + } + ], + "description": "Versioned checklist templates for inspections." + }, + "Checklist.CreateChecklistInput-inspection.write": { + "type": "object", + "description": "Versioned checklist templates for inspections.", + "required": [ + "name", + "version" + ], + "properties": { + "name": { + "maxLength": 255, + "description": "Checklist name", + "default": "", + "example": "Contrôle Extincteur", + "type": "string" + }, + "version": { + "maxLength": 50, + "description": "Version label", + "default": "", + "example": "1.0", + "type": "string" + }, + "referenceCode": { + "maxLength": 40, + "description": "Optional human-facing reference code, unique per organization", + "example": "CHK-EXT-Q", + "type": [ + "string", + "null" + ] + }, + "previousChecklistId": { + "format": "uuid", + "description": "Previous checklist in the same organization. Creates a distinct revision without changing existing inspections.", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "type": [ + "string", + "null" + ] + }, + "items": { + "description": "Checklist items", + "type": "array", + "items": { + "$ref": "#/components/schemas/ChecklistItemInput-inspection.write" + } + } + } + }, + "Checklist.UpdateChecklistInput-inspection.write.jsonMergePatch": { + "type": "object", + "description": "Versioned checklist templates for inspections.", + "properties": { + "name": { + "maxLength": 255, + "description": "Checklist name", + "example": "Contrôle Extincteur", + "type": [ + "string", + "null" + ] + }, + "referenceCode": { + "maxLength": 40, + "description": "Optional human-facing reference code, unique per organization", + "example": "CHK-EXT-Q", + "type": [ + "string", + "null" + ] + }, + "items": { + "description": "Checklist items (full replacement list)", + "type": "array", + "items": { + "$ref": "#/components/schemas/ChecklistItemInput-inspection.write" + } + } + } + }, + "ChecklistItemInput-inspection.write": { + "type": "object", + "required": [ + "label" + ], + "properties": { + "label": { + "maxLength": 255, + "description": "Item label", + "default": "", + "example": "Vérifier la pression", + "type": "string" + }, + "description": { + "maxLength": 1000, + "description": "Optional item description", + "type": [ + "string", + "null" + ] + }, + "required": { + "description": "Whether the item is required", + "default": true, + "example": true, + "type": "boolean" + }, + "position": { + "minimum": 0, + "description": "Display position", + "default": 0, + "example": 0, + "type": "integer" + } + } + }, + "ChecklistItemOutput.jsonld-inspection.read": { + "type": "object", + "properties": { + "id": { + "readOnly": true, + "description": "Property id.", + "default": "", + "type": "string" + }, + "label": { + "readOnly": true, + "description": "Property label.", + "default": "", + "type": "string" + }, + "position": { + "readOnly": true, + "description": "Property position.", + "default": 0, + "type": "integer" + }, + "required": { + "readOnly": true, + "description": "Property required.", + "default": true, + "type": "boolean" + }, + "description": { + "readOnly": true, + "description": "Property description.", + "type": [ + "string", + "null" + ] + } + } + }, + "Client.ClientInput-client.update.jsonMergePatch": { + "type": "object", + "description": "Updates name, redirect URIs, grant types, or scopes of an existing client.", + "properties": { + "client_name": { + "minLength": 3, + "maxLength": 100, + "writeOnly": true, + "description": "Human-readable name of the client application", + "example": "My Awesome App", + "type": "string" + }, + "redirect_uris": { + "writeOnly": true, + "description": "List of allowed redirect URIs for OAuth2 callbacks", + "example": [ + "https://app.example.com/callback" + ], + "type": "array", + "items": { + "type": "string", + "format": "uri" + } + }, + "grant_types": { + "writeOnly": true, + "description": "List of allowed OAuth2 grant types", + "example": [ + "authorization_code", + "refresh_token" + ], + "type": "array", + "items": { + "type": "string", + "enum": [ + "authorization_code", + "client_credentials", + "refresh_token" + ] + } + }, + "scopes": { + "writeOnly": true, + "description": "List of allowed OAuth2 scopes", + "example": [ + "openid", + "profile", + "email" + ], + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "Client.ClientInput-client.write": { + "type": "object", + "description": "Register a new OAuth2 client application. The client secret is shown only once.", + "required": [ + "client_name", + "redirect_uris", + "grant_types", + "scopes" + ], + "properties": { + "client_name": { + "minLength": 3, + "maxLength": 100, + "writeOnly": true, + "description": "Human-readable name of the client application", + "example": "My Awesome App", + "type": "string" + }, + "redirect_uris": { + "writeOnly": true, + "description": "List of allowed redirect URIs for OAuth2 callbacks", + "example": [ + "https://app.example.com/callback" + ], + "type": "array", + "items": { + "type": "string", + "format": "uri" + } + }, + "grant_types": { + "writeOnly": true, + "description": "List of allowed OAuth2 grant types", + "example": [ + "authorization_code", + "refresh_token" + ], + "type": "array", + "items": { + "type": "string", + "enum": [ + "authorization_code", + "client_credentials", + "refresh_token" + ] + } + }, + "scopes": { + "writeOnly": true, + "description": "List of allowed OAuth2 scopes", + "example": [ + "openid", + "profile", + "email" + ], + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "Client.ClientOutput.jsonld-client.read": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "required": [ + "client_id", + "is_active", + "created_at" + ], + "properties": { + "client_id": { + "readOnly": true, + "description": "Unique client identifier (UUID)", + "example": "01234567-89ab-cdef-0123-456789abcdef", + "type": "string", + "format": "uuid" + }, + "client_name": { + "readOnly": true, + "description": "Human-readable name of the client", + "example": "My Awesome App", + "type": "string" + }, + "redirect_uris": { + "readOnly": true, + "description": "List of registered redirect URIs", + "example": [ + "https://app.example.com/callback" + ], + "type": "array", + "items": { + "type": "string", + "format": "uri" + } + }, + "grant_types": { + "readOnly": true, + "description": "List of allowed grant types", + "example": [ + "authorization_code" + ], + "type": "array", + "items": { + "type": "string" + } + }, + "scopes": { + "readOnly": true, + "description": "List of allowed scopes", + "example": [ + "openid", + "profile" + ], + "type": "array", + "items": { + "type": "string" + } + }, + "is_active": { + "readOnly": true, + "description": "Whether the client is currently active", + "default": true, + "example": true, + "type": "boolean" + }, + "created_at": { + "readOnly": true, + "description": "Creation timestamp (ISO 8601)", + "example": "2025-01-01T12:00:00+00:00", + "type": "string", + "format": "date-time" + } + } + } + ], + "description": "Get details of a specific OAuth2 client. Secret is not included." + }, + "Client.ClientOutput.jsonld-client.read_client.secret": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "required": [ + "client_id", + "is_active", + "created_at" + ], + "properties": { + "client_id": { + "readOnly": true, + "description": "Unique client identifier (UUID)", + "example": "01234567-89ab-cdef-0123-456789abcdef", + "type": "string", + "format": "uuid" + }, + "client_name": { + "readOnly": true, + "description": "Human-readable name of the client", + "example": "My Awesome App", + "type": "string" + }, + "client_secret": { + "readOnly": true, + "description": "Client secret (only shown once)", + "example": "your-client-secret", + "type": "string" + }, + "redirect_uris": { + "readOnly": true, + "description": "List of registered redirect URIs", + "example": [ + "https://app.example.com/callback" + ], + "type": "array", + "items": { + "type": "string", + "format": "uri" + } + }, + "grant_types": { + "readOnly": true, + "description": "List of allowed grant types", + "example": [ + "authorization_code" + ], + "type": "array", + "items": { + "type": "string" + } + }, + "scopes": { + "readOnly": true, + "description": "List of allowed scopes", + "example": [ + "openid", + "profile" + ], + "type": "array", + "items": { + "type": "string" + } + }, + "is_active": { + "readOnly": true, + "description": "Whether the client is currently active", + "default": true, + "example": true, + "type": "boolean" + }, + "created_at": { + "readOnly": true, + "description": "Creation timestamp (ISO 8601)", + "example": "2025-01-01T12:00:00+00:00", + "type": "string", + "format": "date-time" + } + } + } + ], + "description": "Register a new OAuth2 client application. The client secret is shown only once." + }, + "ComplianceSummary.ComplianceSummaryOutput.jsonld": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { + "organizationId": { + "description": "Property organizationId.", "default": "", "type": "string" }, - "requestedByUserId": { - "readOnly": true, - "description": "Property requestedByUserId.", + "facilityId": { + "description": "Property facilityId.", + "type": [ + "string", + "null" + ] + }, + "generatedAt": { + "description": "Property generatedAt.", "default": "", "type": "string" }, - "decisionByMemberId": { - "readOnly": true, - "description": "Property decisionByMemberId.", + "dataEvaluatedAt": { + "description": "Oldest successful maintenance evaluation in the scope, distinct from generation time.", "type": [ "string", "null" ] }, - "decisionByUserId": { + "organizationStatus": { + "description": "Property organizationStatus.", + "default": "", + "type": "string" + }, + "totals": { + "description": "Property totals.", + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "integer" + }, + { + "type": "null" + } + ] + } + }, + "facilities": { + "description": "Property facilities.", + "type": "array", + "items": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "integer" + }, + { + "type": "string" + }, + { + "type": "null" + } + ] + } + } + } + } + } + ], + "description": "Regulatory compliance register summary: organization rollup and per-facility breakdown, graded compliant/at_risk/non_compliant/not_applicable from Maintenance due-status, Inspection non-conformity, and Equipment inventory counts. Requires organization.compliance.read." + }, + "ConstraintViolation": { + "type": "object", + "description": "Unprocessable entity", + "properties": { + "status": { + "default": 422, + "type": "integer" + }, + "violations": { + "type": "array", + "items": { + "type": "object", + "properties": { + "propertyPath": { + "type": "string", + "description": "The property path of the violation" + }, + "message": { + "type": "string", + "description": "The message associated with the violation" + }, + "code": { + "type": "string", + "description": "The code of the violation" + }, + "hint": { + "type": "string", + "description": "An extra hint to understand the violation" + }, + "payload": { + "type": "object", + "additionalProperties": true, + "description": "The serialized payload of the violation" + } + }, + "required": [ + "propertyPath", + "message" + ] + } + }, + "detail": { + "readOnly": true, + "type": "string" + }, + "type": { + "readOnly": true, + "type": "string" + }, + "title": { + "readOnly": true, + "type": [ + "string", + "null" + ] + }, + "instance": { + "readOnly": true, + "type": [ + "string", + "null" + ] + } + } + }, + "ConstraintViolation.jsonld": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { + "status": { + "default": 422, + "type": "integer" + }, + "violations": { + "type": "array", + "items": { + "type": "object", + "properties": { + "propertyPath": { + "type": "string", + "description": "The property path of the violation" + }, + "message": { + "type": "string", + "description": "The message associated with the violation" + }, + "code": { + "type": "string", + "description": "The code of the violation" + }, + "hint": { + "type": "string", + "description": "An extra hint to understand the violation" + }, + "payload": { + "type": "object", + "additionalProperties": true, + "description": "The serialized payload of the violation" + } + }, + "required": [ + "propertyPath", + "message" + ] + } + }, + "detail": { + "readOnly": true, + "type": "string" + }, + "description": { + "readOnly": true, + "type": "string" + }, + "type": { + "readOnly": true, + "type": "string" + }, + "title": { "readOnly": true, - "description": "Property decisionByUserId.", "type": [ "string", "null" ] }, - "decisionNote": { + "instance": { "readOnly": true, - "description": "Property decisionNote.", + "type": [ + "string", + "null" + ] + } + } + } + ], + "description": "Unprocessable entity" + }, + "Conversation.AcknowledgeDeliveryInput": { + "type": "object", + "description": "Resource ConversationResource.\n\nContextual discussion threads bound one-to-one to a core-entity subject\n(facility | equipment | intervention | non-conformity in v1). Every\noperation requires `ROLE_USER` at the resource level; the finer-grained\n`organization.messaging.{read,write,manage}` + subject-permission checks\nare enforced in the application layer (mirrors Maintenance/Intervention).", + "required": [ + "messageId" + ], + "properties": { + "messageId": { + "description": "Property messageId", + "default": "", + "type": "string" + } + } + }, + "Conversation.ArchiveConversationInput.jsonMergePatch": { + "type": "object", + "description": "Resource ConversationResource.\n\nContextual discussion threads bound one-to-one to a core-entity subject\n(facility | equipment | intervention | non-conformity in v1). Every\noperation requires `ROLE_USER` at the resource level; the finer-grained\n`organization.messaging.{read,write,manage}` + subject-permission checks\nare enforced in the application layer (mirrors Maintenance/Intervention).", + "properties": { + "isArchived": { + "description": "Property isArchived.", + "default": true, + "type": "boolean" + } + } + }, + "Conversation.ConversationActivityBucketOutput.jsonld": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { + "bucket": { + "description": "Property bucket.", + "default": "", + "type": "string" + }, + "count": { + "description": "Property count.", + "default": 0, + "type": "integer" + } + } + } + ], + "description": "Resource ConversationResource.\n\nContextual discussion threads bound one-to-one to a core-entity subject\n(facility | equipment | intervention | non-conformity in v1). Every\noperation requires `ROLE_USER` at the resource level; the finer-grained\n`organization.messaging.{read,write,manage}` + subject-permission checks\nare enforced in the application layer (mirrors Maintenance/Intervention)." + }, + "Conversation.ConversationOutput.jsonld": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { + "id": { + "description": "Property id.", + "default": "", + "type": "string" + }, + "organization": { + "description": "Property organization.", + "default": "", + "type": "string" + }, + "subjectType": { + "description": "Property subjectType.", + "default": "", + "type": "string" + }, + "subject": { + "description": "Property subject.", "type": [ "string", "null" ] }, - "expiresAt": { - "readOnly": true, - "description": "Property expiresAt.", + "subjectLabel": { + "description": "Property subjectLabel.", + "type": [ + "string", + "null" + ] + }, + "visibility": { + "description": "Property visibility.", "default": "", "type": "string" }, + "lastMessageAt": { + "description": "Property lastMessageAt.", + "type": [ + "string", + "null" + ] + }, + "messagesCount": { + "description": "Property messagesCount.", + "default": 0, + "type": "integer" + }, + "isArchived": { + "description": "Property isArchived.", + "default": false, + "type": "boolean" + }, + "unreadCount": { + "description": "Property unreadCount.", + "default": 0, + "type": "integer" + }, "createdAt": { - "readOnly": true, "description": "Property createdAt.", "default": "", "type": "string" }, "updatedAt": { - "readOnly": true, "description": "Property updatedAt.", "default": "", "type": "string" }, - "decidedAt": { - "readOnly": true, - "description": "Property decidedAt.", + "isChannel": { + "description": "Property isChannel.", + "default": false, + "type": "boolean" + }, + "name": { + "description": "Property name.", "type": [ "string", "null" ] }, - "executedAt": { - "readOnly": true, - "description": "Property executedAt.", + "team": { + "description": "Property team.", "type": [ "string", "null" ] }, - "executionError": { - "readOnly": true, - "description": "Property executionError.", + "isFavorite": { + "description": "Property isFavorite.", + "default": false, + "type": "boolean" + }, + "parentConversationId": { + "description": "Property parentConversationId.", "type": [ "string", "null" ] }, - "allowedActions": { - "description": "available decisions, revalidated by the command", - "type": "array", - "items": { - "type": "string" - } - }, - "decisionBlockReason": { - "description": "Property decisionBlockReason", + "counterpartMember": { + "description": "Property counterpartMember.", "type": [ "string", "null" @@ -46679,186 +60164,159 @@ } } ], - "description": "Organization-scoped four-eyes approval request queue." - }, - "ApprovalRequest.ApproveApprovalRequestInput-Approval.write": { - "type": "object", - "description": "Organization-scoped four-eyes approval request queue.", - "properties": { - "decisionNote": { - "maxLength": 2000, - "description": "Optional free-form decision note", - "type": [ - "string", - "null" - ] - } - } + "description": "Resource ConversationResource.\n\nContextual discussion threads bound one-to-one to a core-entity subject\n(facility | equipment | intervention | non-conformity in v1). Every\noperation requires `ROLE_USER` at the resource level; the finer-grained\n`organization.messaging.{read,write,manage}` + subject-permission checks\nare enforced in the application layer (mirrors Maintenance/Intervention)." }, - "ApprovalRequest.RejectApprovalRequestInput-Approval.write": { - "type": "object", - "description": "Organization-scoped four-eyes approval request queue.", - "properties": { - "decisionNote": { - "maxLength": 2000, - "description": "Optional free-form decision note", - "type": [ - "string", - "null" - ] + "Conversation.ConversationReceiptsOutput.jsonld": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { + "receipts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ConversationReceiptPositionOutput.jsonld" + } + } + } } - } + ], + "description": "Resource ConversationResource.\n\nContextual discussion threads bound one-to-one to a core-entity subject\n(facility | equipment | intervention | non-conformity in v1). Every\noperation requires `ROLE_USER` at the resource level; the finer-grained\n`organization.messaging.{read,write,manage}` + subject-permission checks\nare enforced in the application layer (mirrors Maintenance/Intervention)." }, - "ApprovalRequest.WithdrawApprovalRequestInput-Approval.write": { - "type": "object", - "description": "Organization-scoped four-eyes approval request queue.", - "properties": { - "decisionNote": { - "maxLength": 2000, - "description": "Optional withdrawal reason", - "type": [ - "string", - "null" - ] + "Conversation.ConversationSignalOutput.jsonld": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { + "accepted": { + "description": "Property accepted", + "default": true, + "type": "boolean" + } + } } - } + ], + "description": "Resource ConversationResource.\n\nContextual discussion threads bound one-to-one to a core-entity subject\n(facility | equipment | intervention | non-conformity in v1). Every\noperation requires `ROLE_USER` at the resource level; the finer-grained\n`organization.messaging.{read,write,manage}` + subject-permission checks\nare enforced in the application layer (mirrors Maintenance/Intervention)." }, - "AssistantMessageOutput.jsonld-Assistant.read": { + "Conversation.GetOrCreateConversationInput": { "type": "object", + "description": "Resource ConversationResource.\n\nContextual discussion threads bound one-to-one to a core-entity subject\n(facility | equipment | intervention | non-conformity in v1). Every\noperation requires `ROLE_USER` at the resource level; the finer-grained\n`organization.messaging.{read,write,manage}` + subject-permission checks\nare enforced in the application layer (mirrors Maintenance/Intervention).", + "required": [ + "organization", + "subjectType", + "subject" + ], "properties": { - "id": { - "readOnly": true, - "description": "Property id.", - "default": "", - "type": "string" - }, - "threadId": { - "readOnly": true, - "description": "Property threadId.", - "default": "", - "type": "string" - }, - "organizationId": { - "readOnly": true, - "description": "Property organizationId.", - "default": "", - "type": "string" - }, - "role": { - "readOnly": true, - "description": "Property role.", - "default": "", - "type": "string" - }, - "body": { - "readOnly": true, - "description": "Property body.", + "organization": { + "description": "Property organization.", "default": "", "type": "string" }, - "status": { - "readOnly": true, - "description": "Property status.", + "subjectType": { + "description": "Property subjectType.", "default": "", "type": "string" }, - "errorCode": { - "readOnly": true, - "description": "Property errorCode.", + "subject": { + "description": "Property subject.", + "default": "", + "type": "string" + } + } + }, + "Conversation.MarkConversationReadInput.jsonMergePatch": { + "type": "object", + "description": "Resource ConversationResource.\n\nContextual discussion threads bound one-to-one to a core-entity subject\n(facility | equipment | intervention | non-conformity in v1). Every\noperation requires `ROLE_USER` at the resource level; the finer-grained\n`organization.messaging.{read,write,manage}` + subject-permission checks\nare enforced in the application layer (mirrors Maintenance/Intervention).", + "properties": { + "lastReadMessageId": { + "description": "Property lastReadMessageId.", "type": [ "string", "null" ] + } + } + }, + "Conversation.MessagingSubscriptionOutput.jsonld": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" }, - "tokenCount": { - "readOnly": true, - "description": "Property tokenCount.", - "type": [ - "integer", - "null" - ] - }, - "createdAt": { - "readOnly": true, - "description": "Property createdAt.", + { + "type": "object", + "properties": { + "token": { + "readOnly": true, + "description": "Property token.", + "default": "", + "type": "string" + }, + "topic": { + "readOnly": true, + "description": "Property topic.", + "default": "", + "type": "string" + } + } + } + ], + "description": "Resource ConversationResource.\n\nContextual discussion threads bound one-to-one to a core-entity subject\n(facility | equipment | intervention | non-conformity in v1). Every\noperation requires `ROLE_USER` at the resource level; the finer-grained\n`organization.messaging.{read,write,manage}` + subject-permission checks\nare enforced in the application layer (mirrors Maintenance/Intervention)." + }, + "Conversation.PublishTypingInput": { + "type": "object", + "description": "Resource ConversationResource.\n\nContextual discussion threads bound one-to-one to a core-entity subject\n(facility | equipment | intervention | non-conformity in v1). Every\noperation requires `ROLE_USER` at the resource level; the finer-grained\n`organization.messaging.{read,write,manage}` + subject-permission checks\nare enforced in the application layer (mirrors Maintenance/Intervention).", + "properties": { + "active": { + "description": "Property active", + "default": false, + "type": "boolean" + } + } + }, + "ConversationReceiptPositionOutput.jsonld": { + "type": "object", + "properties": { + "memberId": { + "description": "Property memberId", "default": "", "type": "string" }, - "completedAt": { - "readOnly": true, - "description": "Property completedAt.", + "deliveredMessageId": { + "description": "Property deliveredMessageId", "type": [ "string", "null" ] }, - "attemptId": { - "readOnly": true, - "description": "Property attemptId", + "deliveredThroughAt": { + "description": "Property deliveredThroughAt", "type": [ "string", "null" ] }, - "attemptNumber": { - "readOnly": true, - "description": "Property attemptNumber", - "default": 0, - "type": "integer" - }, - "attemptSequence": { - "readOnly": true, - "description": "Property attemptSequence", - "default": 0, - "type": "integer" - }, - "attemptExpiresAt": { - "readOnly": true, - "description": "Property attemptExpiresAt", + "readMessageId": { + "description": "Property readMessageId", "type": [ "string", "null" ] }, - "canCancel": { - "readOnly": true, - "description": "Property canCancel", - "default": false, - "type": "boolean" - }, - "canRetry": { - "readOnly": true, - "description": "Property canRetry", - "default": false, - "type": "boolean" - } - } - }, - "AssistantThread.AskAssistantQuestionInput-Assistant.write": { - "type": "object", - "description": "Member-private AI-assistant chat threads.", - "required": [ - "body" - ], - "properties": { - "body": { - "maxLength": 8000, - "description": "The question body", - "default": "", - "type": "string" - }, - "temperature": { - "minimum": 0, - "maximum": 2, - "description": "Optional sampling temperature for this question only (0.0-2.0)", + "readThroughAt": { + "description": "Property readThroughAt", "type": [ - "number", + "string", "null" ] } } }, - "AssistantThread.AskAssistantQuestionOutput.jsonld-Assistant.read": { + "CurrentOrganizationMemberProfile.CurrentOrganizationMemberProfileOutput.jsonld-Organization.read": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -46866,9 +60324,9 @@ { "type": "object", "properties": { - "threadId": { + "id": { "readOnly": true, - "description": "Property threadId.", + "description": "Property id.", "default": "", "type": "string" }, @@ -46878,22 +60336,76 @@ "default": "", "type": "string" }, - "userMessage": { + "userId": { "readOnly": true, - "description": "Property userMessage.", - "$ref": "#/components/schemas/AssistantMessageOutput.jsonld-Assistant.read" + "description": "Property userId.", + "default": "", + "type": "string" }, - "assistantMessage": { + "isActive": { "readOnly": true, - "description": "Property assistantMessage.", - "$ref": "#/components/schemas/AssistantMessageOutput.jsonld-Assistant.read" + "description": "Property isActive.", + "default": true, + "type": "boolean" + }, + "joinedAt": { + "readOnly": true, + "description": "Property joinedAt.", + "default": "", + "type": "string" + }, + "roles": { + "readOnly": true, + "type": "array", + "items": { + "$ref": "#/components/schemas/OrganizationRoleOutput.jsonld-Organization.read" + } + }, + "permissions": { + "readOnly": true, + "type": "array", + "items": { + "$ref": "#/components/schemas/OrganizationPermissionOutput.jsonld-Organization.read" + } } } } ], - "description": "Member-private AI-assistant chat threads." + "description": "Resource CurrentOrganizationMemberProfileResource." }, - "AssistantThread.AssistantMessageOutput.jsonld-Assistant.read": { + "CurrentUserProfile.CurrentUserProfileInput-user.write.jsonMergePatch": { + "type": "object", + "description": "Resource CurrentUserProfileResource.", + "properties": { + "firstName": { + "maxLength": 100, + "writeOnly": true, + "description": "User first name.", + "example": "Jane", + "type": "string" + }, + "lastName": { + "maxLength": 100, + "writeOnly": true, + "description": "User last name.", + "example": "Doe", + "type": "string" + }, + "locale": { + "enum": [ + "system", + "en", + "fr", + "es" + ], + "writeOnly": true, + "description": "Preferred display language. Omit to leave unchanged; \"system\" follows the browser language.", + "example": "fr", + "type": "string" + } + } + }, + "CurrentUserProfile.CurrentUserProfileOutput.jsonld-user.read": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -46904,115 +60416,247 @@ "id": { "readOnly": true, "description": "Property id.", - "default": "", - "type": "string" - }, - "threadId": { - "readOnly": true, - "description": "Property threadId.", - "default": "", - "type": "string" + "type": [ + "string", + "null" + ] }, - "organizationId": { + "username": { "readOnly": true, - "description": "Property organizationId.", - "default": "", - "type": "string" + "description": "Property username.", + "type": [ + "string", + "null" + ] }, - "role": { + "email": { "readOnly": true, - "description": "Property role.", - "default": "", - "type": "string" + "description": "Property email.", + "type": [ + "string", + "null" + ] }, - "body": { + "firstName": { "readOnly": true, - "description": "Property body.", - "default": "", - "type": "string" + "description": "Property firstName.", + "type": [ + "string", + "null" + ] }, - "status": { + "lastName": { "readOnly": true, - "description": "Property status.", - "default": "", - "type": "string" + "description": "Property lastName.", + "type": [ + "string", + "null" + ] }, - "errorCode": { + "avatarUrl": { "readOnly": true, - "description": "Property errorCode.", + "description": "Property avatarUrl.", "type": [ "string", "null" ] }, - "tokenCount": { + "status": { "readOnly": true, - "description": "Property tokenCount.", + "description": "Property status.", "type": [ - "integer", + "string", "null" ] }, - "createdAt": { + "emailVerified": { "readOnly": true, - "description": "Property createdAt.", - "default": "", - "type": "string" + "description": "Property emailVerified.", + "default": false, + "type": "boolean" }, - "completedAt": { + "tenantId": { "readOnly": true, - "description": "Property completedAt.", + "description": "Property tenantId.", "type": [ "string", "null" ] }, - "attemptId": { + "createdAt": { "readOnly": true, - "description": "Property attemptId", + "description": "Property createdAt.", "type": [ "string", "null" ] }, - "attemptNumber": { - "readOnly": true, - "description": "Property attemptNumber", - "default": 0, - "type": "integer" - }, - "attemptSequence": { - "readOnly": true, - "description": "Property attemptSequence", - "default": 0, - "type": "integer" - }, - "attemptExpiresAt": { + "lastLoginAt": { "readOnly": true, - "description": "Property attemptExpiresAt", + "description": "Property lastLoginAt.", "type": [ "string", "null" ] }, - "canCancel": { + "locale": { "readOnly": true, - "description": "Property canCancel", - "default": false, - "type": "boolean" + "description": "Preferred display language. \"system\" follows the browser language.", + "default": "system", + "example": "fr", + "type": "string", + "enum": [ + "system", + "en", + "fr", + "es" + ] }, - "canRetry": { + "totpEnabled": { "readOnly": true, - "description": "Property canRetry", + "description": "Whether the authenticated user has an active TOTP (authenticator app) enrollment.", "default": false, + "example": false, "type": "boolean" + }, + "roles": { + "readOnly": true, + "description": "Resolved global role names for the authenticated user.", + "type": "array", + "items": { + "type": "string" + } + }, + "permissions": { + "readOnly": true, + "description": "Resolved global permission names for the authenticated user.", + "type": "array", + "items": { + "type": "string" + } + }, + "avatarUrls": { + "readOnly": true, + "description": "Avatar URLs by size in pixels. Null when no avatar is set or the avatar is hosted externally.", + "type": "object", + "additionalProperties": { + "type": "string", + "format": "uri" + }, + "nullable": true } } } ], - "description": "Member-private AI-assistant chat threads." + "description": "Resource CurrentUserProfileResource." }, - "AssistantThread.AssistantThreadDetailOutput.jsonld-Assistant.read": { + "Customer.ChangeCustomerInput-customer.write.jsonMergePatch": { + "type": "object", + "description": "Class CustomerResource. Internal customer management inside a single organization. @category Resource", + "properties": { + "name": { + "maxLength": 160, + "type": [ + "string", + "null" + ] + }, + "code": { + "maxLength": 80, + "type": [ + "string", + "null" + ] + }, + "email": { + "format": "email", + "maxLength": 254, + "externalDocs": { + "url": "https://schema.org/email" + }, + "type": [ + "string", + "null" + ] + }, + "phone": { + "maxLength": 40, + "type": [ + "string", + "null" + ] + }, + "contacts": { + "maxItems": 50, + "type": [ + "array", + "null" + ], + "items": { + "type": "object", + "additionalProperties": { + "type": [ + "string", + "null" + ] + } + } + } + } + }, + "Customer.CreateCustomerInput-customer.write": { + "type": "object", + "description": "Class CustomerResource. Internal customer management inside a single organization. @category Resource", + "required": [ + "name" + ], + "properties": { + "name": { + "maxLength": 160, + "default": "", + "type": "string" + }, + "code": { + "maxLength": 80, + "type": [ + "string", + "null" + ] + }, + "email": { + "format": "email", + "maxLength": 254, + "externalDocs": { + "url": "https://schema.org/email" + }, + "type": [ + "string", + "null" + ] + }, + "phone": { + "maxLength": 40, + "type": [ + "string", + "null" + ] + }, + "contacts": { + "maxItems": 50, + "type": "array", + "items": { + "type": "object", + "additionalProperties": { + "type": [ + "string", + "null" + ] + } + } + } + } + }, + "Customer.CustomerOutput.jsonld-customer.read": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -47021,91 +60665,71 @@ "type": "object", "properties": { "id": { - "readOnly": true, - "description": "Property id.", "default": "", "type": "string" }, "organizationId": { - "readOnly": true, - "description": "Property organizationId.", "default": "", "type": "string" }, - "memberId": { - "readOnly": true, - "description": "Property memberId.", + "name": { "default": "", "type": "string" }, - "title": { - "readOnly": true, - "description": "Property title.", + "code": { "type": [ "string", "null" ] }, - "model": { - "readOnly": true, - "description": "Property model.", + "email": { "type": [ "string", "null" ] }, - "createdAt": { - "readOnly": true, - "description": "Property createdAt.", - "default": "", - "type": "string" - }, - "updatedAt": { - "readOnly": true, - "description": "Property updatedAt.", - "default": "", - "type": "string" - }, - "lastMessageAt": { - "readOnly": true, - "description": "Property lastMessageAt.", + "phone": { "type": [ "string", "null" ] }, - "messages": { - "readOnly": true, - "description": "Property messages.", + "contacts": { "type": "array", "items": { - "$ref": "#/components/schemas/AssistantMessageOutput.jsonld-Assistant.read" + "type": "object", + "additionalProperties": { + "type": [ + "string", + "null" + ] + } } }, - "messagesPage": { - "readOnly": true, - "description": "Property messagesPage.", - "default": 1, - "type": "integer" + "archivedAt": { + "type": [ + "string", + "null" + ] }, - "messagesItemsPerPage": { - "readOnly": true, - "description": "Property messagesItemsPerPage.", - "default": 50, - "type": "integer" + "createdAt": { + "default": "", + "type": "string" + }, + "updatedAt": { + "default": "", + "type": "string" }, - "messagesTotal": { - "readOnly": true, - "description": "Property messagesTotal.", - "default": 0, + "revision": { + "default": 1, "type": "integer" } } } ], - "description": "Member-private AI-assistant chat threads." + "description": "Class CustomerResource. Internal customer management inside a single organization. @category Resource" }, - "AssistantThread.AssistantThreadOutput.jsonld-Assistant.read": { + "DirectConversation.ConversationOutput.jsonld": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -47114,99 +60738,130 @@ "type": "object", "properties": { "id": { - "readOnly": true, "description": "Property id.", "default": "", "type": "string" }, - "organizationId": { - "readOnly": true, - "description": "Property organizationId.", + "organization": { + "description": "Property organization.", "default": "", "type": "string" }, - "memberId": { - "readOnly": true, - "description": "Property memberId.", + "subjectType": { + "description": "Property subjectType.", "default": "", "type": "string" }, - "title": { - "readOnly": true, - "description": "Property title.", + "subject": { + "description": "Property subject.", "type": [ "string", "null" ] }, - "model": { - "readOnly": true, - "description": "Property model.", + "subjectLabel": { + "description": "Property subjectLabel.", "type": [ "string", "null" ] }, + "visibility": { + "description": "Property visibility.", + "default": "", + "type": "string" + }, + "lastMessageAt": { + "description": "Property lastMessageAt.", + "type": [ + "string", + "null" + ] + }, + "messagesCount": { + "description": "Property messagesCount.", + "default": 0, + "type": "integer" + }, + "isArchived": { + "description": "Property isArchived.", + "default": false, + "type": "boolean" + }, + "unreadCount": { + "description": "Property unreadCount.", + "default": 0, + "type": "integer" + }, "createdAt": { - "readOnly": true, "description": "Property createdAt.", "default": "", "type": "string" }, "updatedAt": { - "readOnly": true, "description": "Property updatedAt.", "default": "", "type": "string" }, - "lastMessageAt": { - "readOnly": true, - "description": "Property lastMessageAt.", + "isChannel": { + "description": "Property isChannel.", + "default": false, + "type": "boolean" + }, + "name": { + "description": "Property name.", "type": [ "string", "null" ] - } - } - } - ], - "description": "Member-private AI-assistant chat threads." - }, - "AssistantThread.AssistantThreadSubscriptionOutput.jsonld-Assistant.read": { - "allOf": [ - { - "$ref": "#/components/schemas/HydraItemBaseSchema" - }, - { - "type": "object", - "properties": { - "token": { - "readOnly": true, - "description": "Property token.", - "default": "", - "type": "string" }, - "topic": { - "readOnly": true, - "description": "Property topic.", - "default": "", - "type": "string" + "team": { + "description": "Property team.", + "type": [ + "string", + "null" + ] + }, + "isFavorite": { + "description": "Property isFavorite.", + "default": false, + "type": "boolean" + }, + "parentConversationId": { + "description": "Property parentConversationId.", + "type": [ + "string", + "null" + ] + }, + "counterpartMember": { + "description": "Property counterpartMember.", + "type": [ + "string", + "null" + ] } } } ], - "description": "Member-private AI-assistant chat threads." + "description": "Resource DirectConversationResource.\n\n1-to-1 private conversations between two organization members (L2.4) — a\n`Conversation` with `subjectType=DIRECT`, `visibility=PARTICIPANTS`, and\n`subjectId` set to the deterministic, order-independent pair key derived\nby `Messaging\\Domain\\Service\\DirectConversationKey`. Mirrors\n`ChannelResource`: a dedicated top-level creation endpoint PLUS its own\n\"list mine\" collection, while messages/read/subscription/favorite reuse\nthe EXISTING `/api/conversations/{id}/...` endpoints unchanged — a direct\nconversation id IS a conversation id. It deliberately does NOT appear in\n`GET /api/conversations` (see `MessagingConversationRepository::list()`) —\n`GET /api/direct-conversations` is the only list surface for a member's\ndirect conversations, scoped exactly like `GET /api/channels` (an INNER\nJOIN on `messaging_participants`), which is what preserves the privacy\ninvariant: a member can never discover a direct conversation they are not\na participant of, through either list." }, - "AssistantThread.ControlAssistantAttemptInput-Assistant.write": { + "DirectConversation.GetOrCreateDirectConversationInput": { "type": "object", - "description": "Member-private AI-assistant chat threads.", + "description": "Resource DirectConversationResource.\n\n1-to-1 private conversations between two organization members (L2.4) — a\n`Conversation` with `subjectType=DIRECT`, `visibility=PARTICIPANTS`, and\n`subjectId` set to the deterministic, order-independent pair key derived\nby `Messaging\\Domain\\Service\\DirectConversationKey`. Mirrors\n`ChannelResource`: a dedicated top-level creation endpoint PLUS its own\n\"list mine\" collection, while messages/read/subscription/favorite reuse\nthe EXISTING `/api/conversations/{id}/...` endpoints unchanged — a direct\nconversation id IS a conversation id. It deliberately does NOT appear in\n`GET /api/conversations` (see `MessagingConversationRepository::list()`) —\n`GET /api/direct-conversations` is the only list surface for a member's\ndirect conversations, scoped exactly like `GET /api/channels` (an INNER\nJOIN on `messaging_participants`), which is what preserves the privacy\ninvariant: a member can never discover a direct conversation they are not\na participant of, through either list.", "required": [ - "attemptId" + "organization", + "memberId" ], "properties": { - "attemptId": { + "organization": { + "description": "Property organization.", + "default": "", + "type": "string" + }, + "memberId": { "format": "uuid", - "description": "Property attemptId", + "description": "Property memberId.", "externalDocs": { "url": "https://schema.org/identifier" }, @@ -47215,239 +60870,322 @@ } } }, - "AssistantThread.StartAssistantThreadInput-Assistant.write": { + "Discovery.JwksOutput-token.read": { "type": "object", - "description": "Member-private AI-assistant chat threads.", + "description": "OAuth2/OIDC discovery endpoints", + "required": [ + "keys" + ], "properties": { - "title": { - "maxLength": 255, - "description": "Optional display title for the thread", - "type": [ - "string", - "null" - ] - }, - "model": { - "maxLength": 255, - "description": "Optional tenant-selected model override, validated against the operator-configured allowlist", - "type": [ - "string", - "null" - ] + "keys": { + "readOnly": true, + "description": "Array of JSON Web Keys for signature verification", + "example": [ + { + "kty": "RSA", + "use": "sig", + "alg": "RS256", + "kid": "key-id-123" + } + ], + "type": "array", + "items": { + "type": "object", + "properties": { + "kty": { + "type": "string", + "description": "Key type (e.g., RSA)" + }, + "use": { + "type": "string", + "description": "Key usage (sig for signature)" + }, + "alg": { + "type": "string", + "description": "Algorithm (e.g., RS256)" + }, + "kid": { + "type": "string", + "description": "Key identifier" + }, + "n": { + "type": "string", + "description": "RSA modulus (Base64URL)" + }, + "e": { + "type": "string", + "description": "RSA exponent (Base64URL)" + } + } + } } } }, - "AuditEvent.AuditEventOutput.jsonld-audit.read": { - "allOf": [ - { - "$ref": "#/components/schemas/HydraItemBaseSchema" + "Discovery.OpenIdConfigurationOutput-token.read": { + "type": "object", + "description": "OAuth2/OIDC discovery endpoints", + "required": [ + "issuer", + "token_endpoint", + "userinfo_endpoint", + "jwks_uri" + ], + "properties": { + "issuer": { + "readOnly": true, + "description": "Issuer identifier URL of the authorization server", + "example": "https://auth.example.com", + "type": "string", + "format": "uri" }, - { - "type": "object", - "required": [ - "id", - "action", - "actorType", - "occurredAt", - "recordedAt", - "chainId", - "sequence", - "eventHash" + "authorization_endpoint": { + "readOnly": true, + "description": "URL of the authorization endpoint", + "example": "https://auth.example.com/oauth2/authorize", + "type": "string", + "format": "uri", + "nullable": true + }, + "token_endpoint": { + "readOnly": true, + "description": "URL of the token endpoint", + "example": "https://auth.example.com/api/oauth2/token", + "type": "string", + "format": "uri" + }, + "userinfo_endpoint": { + "readOnly": true, + "description": "URL of the UserInfo endpoint", + "example": "https://auth.example.com/api/oauth2/userinfo", + "type": "string", + "format": "uri" + }, + "jwks_uri": { + "readOnly": true, + "description": "URL of the JWKS endpoint", + "example": "https://auth.example.com/api/.well-known/jwks.json", + "type": "string", + "format": "uri" + }, + "revocation_endpoint": { + "readOnly": true, + "description": "URL of the token revocation endpoint", + "example": "https://auth.example.com/api/oauth2/token/revoke", + "type": "string", + "format": "uri", + "nullable": true + }, + "introspection_endpoint": { + "readOnly": true, + "description": "URL of the token introspection endpoint", + "example": "https://auth.example.com/api/oauth2/token/introspect", + "type": "string", + "format": "uri", + "nullable": true + }, + "end_session_endpoint": { + "readOnly": true, + "description": "URL of the end session (logout) endpoint", + "example": "https://auth.example.com/api/oauth2/logout", + "type": "string", + "format": "uri", + "nullable": true + }, + "scopes_supported": { + "readOnly": true, + "description": "List of supported OAuth2 scopes", + "example": [ + "openid", + "profile", + "email", + "offline_access" ], - "properties": { - "id": { - "readOnly": true, - "description": "Audit event identifier (UUID).", - "default": "", - "example": "550e8400-e29b-41d4-a716-446655440000", - "type": "string", - "format": "uuid" - }, - "action": { - "readOnly": true, - "description": "Audit action key.", - "default": "", - "example": "auth.login_success", - "type": "string" - }, - "actorType": { - "readOnly": true, - "description": "Actor type (user, client, system, anonymous).", - "default": "", - "example": "user", - "type": "string" - }, - "actorId": { - "readOnly": true, - "description": "Actor identifier (UUID or external ID).", - "example": "0c1b3f6a-8c2e-4f5d-9c0a-2c2b7b9f1c7a", - "type": "string", - "nullable": true - }, - "actorEmail": { - "readOnly": true, - "description": "Actor email (optional, depending on PII settings).", - "example": "user@example.com", - "type": "string", - "nullable": true - }, - "actorEmailHash": { - "readOnly": true, - "description": "Actor email hash (SHA-256).", - "example": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", - "type": "string", - "nullable": true - }, - "subjectType": { - "readOnly": true, - "description": "Subject type (token, user, client, etc.).", - "example": "token", - "type": "string", - "nullable": true - }, - "subjectId": { - "readOnly": true, - "description": "Subject identifier (UUID or token ID).", - "example": "f4d3b0f0-2b4c-4cdd-9d5a-2d2fd9b3c1a1", - "type": "string", - "nullable": true - }, - "clientId": { - "readOnly": true, - "description": "Client identifier (UUID).", - "example": "3e9d2c1a-72d4-4b18-9d4d-9a1234567890", - "type": "string", - "nullable": true - }, - "tenantId": { - "readOnly": true, - "description": "Tenant identifier (UUID).", - "example": "00000000-0000-4000-8000-000000000001", - "type": "string", - "nullable": true - }, - "ipAddress": { - "readOnly": true, - "description": "Client IP address (optional, depending on PII settings).", - "example": "203.0.113.5", - "type": "string", - "nullable": true - }, - "ipHash": { - "readOnly": true, - "description": "IP hash (SHA-256).", - "example": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", - "type": "string", - "nullable": true - }, - "userAgent": { - "readOnly": true, - "description": "User agent string (if available).", - "example": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36", - "type": "string", - "nullable": true - }, - "metadata": { - "readOnly": true, - "description": "Event metadata payload (JSON).", - "example": { - "ip": "203.0.113.5", - "action": "login", - "success": true - }, - "type": "object", - "additionalProperties": true - }, - "occurredAt": { - "readOnly": true, - "description": "ISO 8601 datetime when the event occurred.", - "default": "", - "example": "2026-01-30T09:45:00+00:00", - "type": "string", - "format": "date-time" - }, - "recordedAt": { - "readOnly": true, - "description": "ISO 8601 datetime when the event was recorded.", - "default": "", - "example": "2026-01-30T09:45:01+00:00", - "type": "string", - "format": "date-time" - }, - "chainId": { - "readOnly": true, - "description": "Audit chain identifier.", - "default": "", - "example": "tenant:00000000-0000-4000-8000-000000000001", - "type": "string" - }, - "sequence": { - "readOnly": true, - "description": "Monotonic sequence number within the chain.", - "default": 0, - "example": 123, - "type": "integer" - }, - "prevHash": { - "readOnly": true, - "description": "Previous event hash in the chain.", - "example": "cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce", - "type": "string", - "nullable": true + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "response_types_supported": { + "readOnly": true, + "description": "List of supported response types", + "example": [ + "code" + ], + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "grant_types_supported": { + "readOnly": true, + "description": "List of supported grant types", + "example": [ + "client_credentials", + "refresh_token", + "authorization_code" + ], + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "token_endpoint_auth_methods_supported": { + "readOnly": true, + "description": "Supported token endpoint authentication methods", + "example": [ + "client_secret_post" + ], + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "code_challenge_methods_supported": { + "readOnly": true, + "description": "Supported PKCE code challenge methods", + "example": [ + "S256", + "plain" + ], + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "prompt_values_supported": { + "readOnly": true, + "description": "Supported prompt values", + "example": [ + "none", + "login", + "consent", + "select_account" + ], + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "subject_types_supported": { + "readOnly": true, + "description": "Supported subject identifier types", + "example": [ + "public" + ], + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "id_token_signing_alg_values_supported": { + "readOnly": true, + "description": "Supported ID token signing algorithms", + "example": [ + "RS256" + ], + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + }, + "claims_supported": { + "readOnly": true, + "description": "Supported OpenID Connect claims", + "example": [ + "sub", + "name", + "given_name", + "family_name", + "preferred_username", + "picture", + "email", + "email_verified", + "auth_time" + ], + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + } + } + }, + "EmailChange.ConfirmEmailChangeInput-email_change.write": { + "type": "object", + "description": "Sign-in email address change operations (email confirmation protected)", + "required": [ + "token" + ], + "properties": { + "token": { + "maxLength": 128, + "writeOnly": true, + "description": "Confirmation token from the email sent to the new address", + "example": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "type": "string" + } + } + }, + "EmailChange.ConfirmEmailChangeOutput.jsonld-email_change.read": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { + "success": { + "description": "Whether the email address was changed successfully", + "example": true, + "type": "boolean" }, - "eventHash": { - "readOnly": true, - "description": "Hash of the event payload (SHA-256).", - "default": "", - "example": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "message": { + "description": "Informational message", + "example": "Your email address has been changed. Please sign in again with the new address.", "type": "string" } } } ], - "description": "Returns a paginated list of audit events." + "description": "Sign-in email address change operations (email confirmation protected)" }, - "Auth.LoginInput-token.write": { + "EmailChange.RequestEmailChangeInput-email_change.write": { "type": "object", - "description": "User authentication operations", + "description": "Sign-in email address change operations (email confirmation protected)", "required": [ - "email", - "password" + "newEmail", + "currentPassword" ], "properties": { - "email": { + "newEmail": { "format": "email", + "maxLength": 320, "writeOnly": true, - "description": "Email address of the user attempting to authenticate", + "description": "The new sign-in email address; a confirmation link is sent to it", "externalDocs": { "url": "https://schema.org/email" }, - "example": "john.doe@example.com", - "type": "string", - "minLength": 5, - "maxLength": 255, - "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$" + "example": "new-address@example.com", + "type": "string" }, - "password": { - "minLength": 8, - "maxLength": 128, + "currentPassword": { "writeOnly": true, - "description": "Password of the user (write-only, never returned in responses)", - "example": "SecureP@ssw0rd!", + "description": "Current password, verified before the confirmation email is sent", + "example": "CurrentP@ssw0rd!", "type": "string", "format": "password" - }, - "remember_me": { - "writeOnly": true, - "description": "Extend session duration (30 days if true, default 1 day if false)", - "default": false, - "example": false, - "type": "boolean", - "nullable": false } } }, - "Auth.LoginOutput.jsonld-token.read": { + "EmailChange.RequestEmailChangeOutput.jsonld-email_change.read": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -47455,163 +61193,238 @@ { "type": "object", "properties": { - "access_token": { - "readOnly": true, - "description": "JWT access token for API authentication; absent while MFA is required", - "example": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...", - "type": "string" - }, - "token_type": { - "readOnly": true, - "description": "Token type; absent while MFA is required", - "example": "Bearer", - "type": "string", - "enum": [ - "Bearer" - ], - "default": "Bearer" - }, - "expires_in": { - "readOnly": true, - "description": "Token lifetime in seconds; absent while MFA is required", - "example": 3600, - "type": "integer", - "minimum": 1 - }, - "scope": { - "readOnly": true, - "description": "Space-separated list of granted scopes", - "example": "openid profile email", - "type": "string", - "nullable": true - }, - "mfa_required": { - "readOnly": true, - "description": "If true, authentication is incomplete. Use mfa_token to verify code.", + "success": { + "description": "Whether the email change request was accepted", "example": true, - "type": "boolean", - "nullable": true - }, - "mfa_token": { - "readOnly": true, - "description": "Temporary Pre-Auth Token (JWT) covering the partial authentication state.", - "example": "eyJ...", - "type": "string", - "nullable": true - }, - "challenge_token": { - "readOnly": true, - "description": "The OTP challenge token (reference).", - "example": "abc...", - "type": "string", - "nullable": true - }, - "mfa_method": { - "readOnly": true, - "description": "The delivery method for the MFA code (email, sms, totp).", - "example": "email", - "type": "string", - "enum": [ - "email", - "sms", - "totp" - ], - "nullable": true - }, - "mfa_destination": { - "readOnly": true, - "description": "The masked destination where the code was sent (e.g., \"j***e@e****e.com\" or \"+336****5678\").", - "example": "j***e@e****e.com", - "type": "string", - "nullable": true + "type": "boolean" }, - "mfa_resend_in": { - "readOnly": true, - "description": "Seconds until MFA code can be resent", - "example": 60, - "type": "integer", - "nullable": true + "message": { + "description": "Informational message", + "example": "A confirmation link has been sent to the new email address.", + "type": "string" }, - "return_url": { - "description": "Validated local destination returned by a federated login.", + "expiresAt": { + "description": "When the confirmation token expires", + "example": "2026-08-28T11:00:00+00:00", "type": [ "string", "null" - ] - }, - "new_account": { - "description": "Whether a federated login provisioned the Fireguard account.", - "type": [ - "boolean", - "null" - ] + ], + "format": "date-time" } } } ], - "description": "User authentication operations" + "description": "Sign-in email address change operations (email confirmation protected)" }, - "Auth.LogoutOutput.jsonld-token.read": { + "EmailOwnership.ConfirmEmailOwnershipInput-email_ownership.write": { + "type": "object", + "description": "Resource EmailOwnershipResource.", + "required": [ + "challengeToken", + "code" + ], + "properties": { + "challengeToken": { + "default": "", + "type": "string" + }, + "code": { + "default": "", + "type": "string" + } + } + }, + "EmailOwnership.EmailOwnershipOutput.jsonld-email_ownership.read": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" }, { "type": "object", - "required": [ - "message" - ], "properties": { - "message": { - "readOnly": true, - "description": "Confirmation message indicating successful logout", - "default": "Logged out successfully", - "example": "Logged out successfully", - "type": "string" + "verified": { + "description": "current mailbox ownership proof", + "type": "boolean" } } } ], - "description": "User authentication operations" + "description": "Resource EmailOwnershipResource." }, - "Auth.MfaResendInput": { - "type": "object", - "description": "User authentication operations", - "required": [ - "preAuthToken" - ], - "properties": { - "preAuthToken": { - "writeOnly": true, - "description": "The Pre-Auth Token received from the login response.", - "example": "eyJ...", - "type": "string" + "EmailOwnership.StartEmailOwnershipOutput.jsonld-email_ownership.read": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { + "challengeToken": { + "description": "single-use email challenge token", + "type": "string" + }, + "canResendIn": { + "description": "seconds until resend", + "type": "integer" + } + } } - } + ], + "description": "Resource EmailOwnershipResource." }, - "Auth.MfaVerifyInput": { + "Equipment.CreateEquipmentInput": { "type": "object", - "description": "User authentication operations", - "required": [ - "preAuthToken", - "code" - ], + "description": "Resource CanonicalEquipmentResource.", "properties": { - "preAuthToken": { - "writeOnly": true, - "description": "The Pre-Auth Token received from the login response.", - "example": "eyJ...", - "type": "string" + "name": { + "maxLength": 255, + "type": [ + "string", + "null" + ] }, - "code": { - "writeOnly": true, - "description": "The OTP code received by the user.", - "example": "123456", + "assetCode": { + "maxLength": 100, + "type": [ + "string", + "null" + ] + }, + "criticality": { + "enum": [ + "low", + "medium", + "high", + "critical" + ], + "type": [ + "string", + "null" + ] + }, + "technicalProperties": { + "maxItems": 50, + "type": "array", + "items": { + "type": "object", + "additionalProperties": { + "type": [ + "string", + "null" + ] + } + } + }, + "clientId": { + "format": "uuid", + "description": "Client-generated UUID for idempotent offline creation", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "type": [ + "string", + "null" + ] + }, + "organization": { + "pattern": "^(/api/organizations/[0-9a-fA-F-]{36})$", + "description": "Organization IRI, required on the canonical endpoint", + "type": [ + "string", + "null" + ] + }, + "intervention": { + "pattern": "^(/api/interventions/[0-9a-fA-F-]{36})$", + "description": "Optional field intervention IRI", + "type": [ + "string", + "null" + ] + }, + "facility": { + "pattern": "^(/api/facilities/[0-9a-fA-F-]{36})$", + "description": "Optional facility IRI", + "type": [ + "string", + "null" + ] + }, + "onboardingSessionId": { + "format": "uuid", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "type": [ + "string", + "null" + ] + }, + "onboardingItemKey": { + "type": [ + "string", + "null" + ] + }, + "type": { + "maxLength": 32, + "description": "Equipment type", + "default": "", + "example": "fire_extinguisher", "type": "string" + }, + "subType": { + "maxLength": 100, + "description": "Optional free-form sub-type", + "example": "CO2 6kg", + "type": [ + "string", + "null" + ] + }, + "brand": { + "maxLength": 100, + "description": "Optional brand name", + "example": "Sicli", + "type": [ + "string", + "null" + ] + }, + "model": { + "maxLength": 100, + "description": "Optional model name", + "example": "Pro 6", + "type": [ + "string", + "null" + ] + }, + "serialNumber": { + "maxLength": 100, + "description": "Optional serial number (unique per organization)", + "example": "SN-2024-001", + "type": [ + "string", + "null" + ] + }, + "locationLabel": { + "maxLength": 255, + "description": "Optional free-form location label", + "example": "Near staircase B", + "type": [ + "string", + "null" + ] } - } + }, + "required": [ + "type" + ] }, - "AutomationAttempt.AutomationAttemptOutput.jsonld": { + "Equipment.EquipmentOutput.jsonld": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -47619,230 +61432,220 @@ { "type": "object", "properties": { - "id": { - "description": "Method __construct", - "type": "string" + "name": { + "type": [ + "string", + "null" + ] }, - "runId": { - "description": "Method __construct", - "type": "string" + "assetCode": { + "type": [ + "string", + "null" + ] }, - "organizationId": { - "description": "Method __construct", - "type": "string" + "criticality": { + "type": [ + "string", + "null" + ] }, - "ruleKey": { - "description": "Method __construct", - "type": "string" + "technicalProperties": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": { + "type": [ + "string", + "null" + ] + } + } }, - "subjectId": { - "description": "Method __construct", + "predecessorEquipmentId": { + "type": [ + "string", + "null" + ] + }, + "successorEquipmentId": { + "type": [ + "string", + "null" + ] + }, + "intervention": { + "description": "Property intervention.", + "type": [ + "string", + "null" + ] + }, + "recordStatus": { + "description": "Property recordStatus.", + "default": "published", "type": "string" }, - "attemptNumber": { - "description": "Method __construct", + "revision": { + "description": "Property revision.", + "default": 1, "type": "integer" }, - "status": { - "description": "Method __construct", + "id": { + "readOnly": true, + "description": "Property id.", + "default": "", + "type": "string" + }, + "organizationId": { + "readOnly": true, + "description": "Property organizationId.", + "default": "", "type": "string" }, - "createdAt": { - "description": "Method __construct", + "facilityId": { + "readOnly": true, + "description": "Property facilityId.", + "type": [ + "string", + "null" + ] + }, + "type": { + "readOnly": true, + "description": "Property type.", + "default": "", "type": "string" }, - "finishedAt": { - "description": "Method __construct", + "subType": { + "readOnly": true, + "description": "Property subType.", "type": [ "string", "null" ] }, - "requestedBy": { - "description": "Method __construct", + "brand": { + "readOnly": true, + "description": "Property brand.", "type": [ "string", "null" ] }, - "interventionId": { - "description": "Method __construct", + "model": { + "readOnly": true, + "description": "Property model.", "type": [ "string", "null" ] }, - "errorCode": { - "description": "Method __construct", + "serialNumber": { + "readOnly": true, + "description": "Property serialNumber.", "type": [ "string", "null" ] }, - "canRetry": { - "description": "Method __construct", - "type": "boolean" - } - } - } - ], - "description": "Resource AutomationResource. Separate organization history and management access." - }, - "AutomationAttempt.AutomationPolicyOutput.jsonld": { - "allOf": [ - { - "$ref": "#/components/schemas/HydraItemBaseSchema" - }, - { - "type": "object", - "properties": { - "id": { - "description": "Method __construct", - "type": "string" - }, - "ruleKey": { - "description": "Method __construct", - "type": "string" - }, - "enabled": { - "description": "Method __construct", - "type": "boolean" + "locationLabel": { + "readOnly": true, + "description": "Property locationLabel.", + "type": [ + "string", + "null" + ] }, - "canManage": { - "description": "Method __construct", - "type": "boolean" - } - } - } - ], - "description": "Resource AutomationResource. Separate organization history and management access." - }, - "AutomationAttempt.RetryAutomationAttemptInput": { - "type": "object", - "description": "Resource AutomationResource. Separate organization history and management access.", - "required": [ - "attemptId" - ], - "properties": { - "attemptId": { - "format": "uuid", - "description": "Property attemptId", - "externalDocs": { - "url": "https://schema.org/identifier" - }, - "default": "", - "type": "string" - } - } - }, - "BillingPricing.PlanPricingOutput.jsonld-Billing.read": { - "allOf": [ - { - "$ref": "#/components/schemas/HydraItemBaseSchema" - }, - { - "type": "object", - "properties": { - "planKey": { + "facilityName": { "readOnly": true, - "description": "Property planKey.", - "default": "", - "type": "string" + "description": "Display name of the assigned facility", + "type": [ + "string", + "null" + ] }, - "currency": { + "status": { "readOnly": true, - "description": "Property currency.", + "description": "Property status.", "default": "", "type": "string" }, - "monthlyAmount": { + "installedAt": { "readOnly": true, - "description": "Property monthlyAmount.", + "description": "Property installedAt.", "type": [ - "integer", + "string", "null" ] }, - "yearlyAmount": { + "commissionedAt": { "readOnly": true, - "description": "Property yearlyAmount.", + "description": "Property commissionedAt.", "type": [ - "integer", + "string", "null" ] - } - } - } - ], - "description": "Subscription plan pricing." - }, - "CalendarEvent.CalendarEventOutput.jsonld-Calendar.read": { - "allOf": [ - { - "$ref": "#/components/schemas/HydraItemBaseSchema" - }, - { - "type": "object", - "properties": { - "id": { - "readOnly": true, - "description": "Property id.", - "default": "", - "type": "string" }, - "organizationId": { + "tags": { "readOnly": true, - "description": "Property organizationId.", - "default": "", - "type": "string" + "description": "Property tags.", + "type": "array", + "items": { + "$ref": "#/components/schemas/TagOutput.jsonld" + } }, - "title": { + "maintenanceDueStatus": { "readOnly": true, - "description": "Property title.", - "default": "", + "description": "Property maintenanceDueStatus.", + "default": "unscheduled", "type": "string" }, - "description": { + "controlDueStatus": { "readOnly": true, - "description": "Property description.", - "type": [ - "string", - "null" - ] + "description": "Due status of control operations, independent from service operations and anomalies.", + "default": "unscheduled", + "type": "string" }, - "startsAt": { + "serviceDueStatus": { "readOnly": true, - "description": "Property startsAt.", - "default": "", + "description": "Due status of service operations, independent from periodic controls.", + "default": "unscheduled", "type": "string" }, - "endsAt": { + "controlNextDueAt": { "readOnly": true, - "description": "Property endsAt.", + "description": "Next control deadline in ISO 8601, when scheduled.", "type": [ "string", "null" ] }, - "allDay": { - "readOnly": true, - "description": "Property allDay.", - "default": false, - "type": "boolean" - }, - "facilityId": { + "serviceNextDueAt": { "readOnly": true, - "description": "Property facilityId.", + "description": "Next service deadline in ISO 8601, when scheduled.", "type": [ "string", "null" ] }, - "createdByMemberId": { + "planPosition": { "readOnly": true, - "description": "Property createdByMemberId.", - "default": "", - "type": "string" + "description": "Position pinned on a floor plan attachment (detail read only)", + "type": [ + "object", + "null" + ], + "additionalProperties": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ] + } }, "createdAt": { "readOnly": true, @@ -47859,115 +61662,153 @@ } } ], - "description": "Organization-scoped standalone calendar event management." + "description": "Resource CanonicalEquipmentResource." }, - "CalendarEvent.CreateCalendarEventInput-Calendar.write": { + "Equipment.PatchCanonicalEquipmentInput.jsonMergePatch": { "type": "object", - "description": "Organization-scoped standalone calendar event management.", - "required": [ - "title", - "startsAt" - ], + "description": "Resource CanonicalEquipmentResource.", "properties": { - "title": { + "name": { "maxLength": 255, - "description": "Event title", - "default": "", - "example": "Fire drill", - "type": "string" + "type": [ + "string", + "null" + ] }, - "description": { - "maxLength": 5000, - "description": "Free-form description", + "assetCode": { + "maxLength": 100, "type": [ "string", "null" ] }, - "startsAt": { - "description": "Event start, ISO 8601 with an explicit timezone offset", - "externalDocs": { - "url": "https://schema.org/DateTime" - }, - "default": "", - "example": "2026-08-01T09:00:00+02:00", - "type": "string" + "criticality": { + "enum": [ + "low", + "medium", + "high", + "critical" + ], + "type": [ + "string", + "null" + ] }, - "endsAt": { - "description": "Event end, ISO 8601 with an explicit timezone offset", - "externalDocs": { - "url": "https://schema.org/DateTime" - }, - "example": "2026-08-01T11:00:00+02:00", + "technicalProperties": { + "maxItems": 50, + "type": "array", + "items": { + "type": "object", + "additionalProperties": { + "type": [ + "string", + "null" + ] + } + } + }, + "type": { + "maxLength": 32, + "description": "Property type.", "type": [ "string", "null" ] }, - "allDay": { - "description": "Whether the event spans whole day(s)", - "default": false, - "type": "boolean" + "subType": { + "maxLength": 100, + "description": "Property subType.", + "type": [ + "string", + "null" + ] }, - "facilityId": { - "description": "Associated facility identifier", + "brand": { + "maxLength": 100, + "description": "Property brand.", "type": [ "string", "null" ] - } - } - }, - "CalendarEvent.UpdateCalendarEventInput-Calendar.write.jsonMergePatch": { - "type": "object", - "description": "Organization-scoped standalone calendar event management.", - "properties": { - "title": { - "minLength": 1, - "maxLength": 255, - "description": "Event title", + }, + "model": { + "maxLength": 100, + "description": "Property model.", "type": [ "string", "null" ] }, - "description": { - "maxLength": 5000, - "description": "Free-form description", + "serialNumber": { + "maxLength": 100, + "description": "Property serialNumber.", "type": [ "string", "null" ] }, - "startsAt": { - "description": "Event start, ISO 8601 with an explicit timezone offset", - "externalDocs": { - "url": "https://schema.org/DateTime" - }, + "locationLabel": { + "maxLength": 255, + "description": "Property locationLabel.", "type": [ "string", "null" ] }, - "endsAt": { - "description": "Event end, ISO 8601 with an explicit timezone offset", - "externalDocs": { - "url": "https://schema.org/DateTime" - }, + "status": { + "enum": [ + "in_stock", + "operational", + "decommissioned", + "under_maintenance" + ], + "description": "Property status.", "type": [ "string", "null" ] }, - "allDay": { - "description": "Whether the event spans whole day(s)", + "facility": { + "description": "Property facility.", "type": [ - "boolean", + "string", "null" ] + } + } + }, + "EquipmentAttachment.AddAttachmentInput-equipment.write": { + "type": "object", + "description": "File attachments linked to equipment items.", + "required": [ + "fileName", + "content", + "mimeType" + ], + "properties": { + "fileName": { + "maxLength": 255, + "description": "Original file name", + "default": "", + "example": "maintenance_report.pdf", + "type": "string" }, - "facilityId": { - "description": "Associated facility identifier", + "content": { + "description": "Base64-encoded file contents", + "default": "", + "type": "string" + }, + "mimeType": { + "maxLength": 100, + "description": "MIME type", + "default": "", + "example": "application/pdf", + "type": "string" + }, + "label": { + "maxLength": 255, + "description": "Optional human-readable label", + "example": "Annual maintenance report 2024", "type": [ "string", "null" @@ -47975,7 +61816,7 @@ } } }, - "CalendarFeed.CalendarFeedOutput.jsonld-Calendar.read": { + "EquipmentAttachment.AttachmentOutput.jsonld-equipment.read": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -47983,144 +61824,62 @@ { "type": "object", "properties": { - "from": { + "id": { "readOnly": true, - "description": "Property from.", + "description": "Property id.", "default": "", "type": "string" }, - "to": { + "equipmentId": { "readOnly": true, - "description": "Property to.", + "description": "Property equipmentId.", "default": "", "type": "string" }, - "items": { + "fileName": { "readOnly": true, - "description": "Property items.", - "type": "array", - "items": { - "$ref": "#/components/schemas/CalendarFeedItemOutput.jsonld-Calendar.read" - } + "description": "Property fileName.", + "default": "", + "type": "string" }, - "sources": { + "mimeType": { "readOnly": true, - "type": "array", - "items": { - "$ref": "#/components/schemas/CalendarFeedSourceOutput.jsonld-Calendar.read" - } + "description": "Property mimeType.", + "default": "", + "type": "string" }, - "complete": { + "size": { "readOnly": true, - "description": "Property complete", - "default": true, - "type": "boolean" - } - } - } - ], - "description": "Unified organization calendar feed (standalone events, inspections, interventions, preventive-maintenance due dates)." - }, - "CalendarFeedItemOutput.jsonld-Calendar.read": { - "type": "object", - "properties": { - "sourceKey": { - "readOnly": true, - "description": "Property sourceKey.", - "default": "", - "type": "string" - }, - "id": { - "readOnly": true, - "description": "Property id.", - "default": "", - "type": "string" - }, - "title": { - "readOnly": true, - "description": "Property title.", - "default": "", - "type": "string" - }, - "description": { - "readOnly": true, - "description": "Property description.", - "type": [ - "string", - "null" - ] - }, - "startsAt": { - "readOnly": true, - "description": "Property startsAt.", - "default": "", - "type": "string" - }, - "endsAt": { - "readOnly": true, - "description": "Property endsAt.", - "type": [ - "string", - "null" - ] - }, - "allDay": { - "readOnly": true, - "description": "Property allDay.", - "default": false, - "type": "boolean" - }, - "facilityId": { - "readOnly": true, - "description": "Property facilityId.", - "type": [ - "string", - "null" - ] - }, - "status": { - "readOnly": true, - "description": "Property status.", - "type": [ - "string", - "null" - ] - }, - "targetType": { - "readOnly": true, - "description": "Property targetType.", - "default": "", - "type": "string" - }, - "targetId": { - "readOnly": true, - "description": "Property targetId.", - "default": "", - "type": "string" - } - } - }, - "CalendarFeedSourceOutput.jsonld-Calendar.read": { - "type": "object", - "properties": { - "sourceKey": { - "description": "Property sourceKey", - "default": "", - "type": "string" - }, - "available": { - "description": "Property available", - "default": true, - "type": "boolean" - }, - "truncated": { - "description": "Property truncated", - "default": false, - "type": "boolean" + "description": "Property size.", + "default": 0, + "type": "integer" + }, + "label": { + "readOnly": true, + "description": "Property label.", + "type": [ + "string", + "null" + ] + }, + "revision": { + "readOnly": true, + "description": "Property revision.", + "default": 1, + "type": "integer" + }, + "uploadedAt": { + "readOnly": true, + "description": "Property uploadedAt.", + "default": "", + "type": "string" + } + } } - } + ], + "description": "File attachments linked to equipment items." }, - "CalendarFeedToken.CalendarFeedTokenOutput.jsonld-Calendar.read": { + "EquipmentInspectionSummary.EquipmentInspectionSummaryOutput.jsonld": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -48128,15 +61887,45 @@ { "type": "object", "properties": { - "createdAt": { + "equipmentId": { "readOnly": true, - "description": "Property createdAt.", "default": "", "type": "string" }, - "lastUsedAt": { + "openAnomalies": { + "readOnly": true, + "default": 0, + "type": "integer" + }, + "bySeverity": { + "readOnly": true, + "default": { + "low": 0, + "medium": 0, + "high": 0, + "critical": 0 + }, + "type": "object", + "additionalProperties": { + "type": "integer" + } + }, + "lastInspectionId": { + "readOnly": true, + "type": [ + "string", + "null" + ] + }, + "lastInspectionPerformedAt": { + "readOnly": true, + "type": [ + "string", + "null" + ] + }, + "lastInspectionResult": { "readOnly": true, - "description": "Property lastUsedAt.", "type": [ "string", "null" @@ -48145,9 +61934,9 @@ } } ], - "description": "Member-scoped iCal calendar feed subscription token." + "description": "Published controls and open findings for one equipment; requires both equipment and inspection read permissions." }, - "CalendarFeedToken.CalendarFeedTokenSecretOutput.jsonld-Calendar.read": { + "EquipmentKpi.EquipmentKpiOutput.jsonld-equipment.read": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -48155,323 +61944,193 @@ { "type": "object", "properties": { - "secret": { + "totalAssets": { "readOnly": true, - "description": "Property secret.", - "default": "", - "type": "string" + "description": "Property totalAssets.", + "default": 0, + "type": "integer" }, - "feedUrl": { + "compliant": { "readOnly": true, - "description": "Property feedUrl.", - "default": "", - "type": "string" + "description": "Property compliant.", + "default": 0, + "type": "integer" }, - "createdAt": { + "dueSoon": { "readOnly": true, - "description": "Property createdAt.", - "default": "", - "type": "string" + "description": "Property dueSoon.", + "default": 0, + "type": "integer" }, - "rotated": { + "openNonConformities": { "readOnly": true, - "description": "Property rotated.", - "default": false, - "type": "boolean" + "description": "Property openNonConformities.", + "default": 0, + "type": "integer" } } } ], - "description": "Member-scoped iCal calendar feed subscription token." - }, - "CapacityConfigurationView.jsonld": { - "type": "object", - "properties": { - "weeks": { - "type": "array", - "items": { - "$ref": "#/components/schemas/CapacityWeekView.jsonld" - } - }, - "exceptions": { - "type": "array", - "items": { - "$ref": "#/components/schemas/CapacityExceptionView.jsonld" - } - } - } - }, - "CapacityExceptionView.jsonld": { - "type": "object", - "properties": { - "id": { - "description": "stable resource identifier, retained across idempotent retries", - "type": "string" - }, - "memberId": { - "description": "organization member whose work or capacity is represented", - "type": "string" - }, - "startsOn": { - "description": "inclusive organization-local start date", - "type": "string" - }, - "endsOn": { - "description": "inclusive organization-local end date", - "type": "string" - }, - "minutes": { - "description": "duration in whole minutes", - "type": "integer" - } - } - }, - "CapacityWeekView.jsonld": { - "type": "object", - "properties": { - "id": { - "description": "stable resource identifier, retained across idempotent retries", - "type": "string" - }, - "scopeId": { - "description": "organization identifier for defaults, or member identifier for an individual override", - "type": "string" - }, - "effectiveOn": { - "description": "first local date on which this capacity version applies", - "type": "string" - }, - "minutes": { - "type": "array", - "items": { - "type": "integer" - } - } - } + "description": "Aggregate equipment KPI counters for the organization equipment overview page." }, - "Challenge.ChallengeOutput.jsonld": { + "EquipmentMaintenanceLog.MaintenanceLogOutput.jsonld-equipment.read": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" }, { "type": "object", - "required": [ - "token", - "purpose", - "channel", - "maskedRecipient", - "status", - "expiresIn", - "attemptsRemaining", - "canResendIn" - ], "properties": { - "token": { + "id": { "readOnly": true, - "description": "Challenge token to use in GET/verify/resend endpoints", - "example": "a1b2c3d4e5f6...", + "description": "Property id.", + "default": "", "type": "string" }, - "purpose": { + "equipmentId": { "readOnly": true, - "description": "The purpose of this OTP challenge", - "example": "login", + "description": "Property equipmentId.", + "default": "", "type": "string" }, - "channel": { + "organizationId": { "readOnly": true, - "description": "The delivery channel used", - "example": "email", + "description": "Property organizationId.", + "default": "", "type": "string" }, - "maskedRecipient": { + "startedAt": { "readOnly": true, - "description": "Masked recipient for user-friendly display", - "example": "jo***@example.com", + "description": "Property startedAt.", + "default": "", "type": "string" }, - "status": { + "completedAt": { "readOnly": true, - "description": "Current status: pending, verified, expired, or failed", - "example": "pending", - "type": "string", - "enum": [ - "pending", - "verified", - "expired", - "failed" + "description": "Property completedAt.", + "type": [ + "string", + "null" ] }, - "expiresIn": { + "source": { "readOnly": true, - "description": "Seconds remaining until the challenge expires", - "example": 285, - "type": "integer" + "description": "Property source.", + "default": "status_transition", + "type": "string" }, - "attemptsRemaining": { + "interventionId": { "readOnly": true, - "description": "Number of verification attempts remaining", - "example": 4, - "type": "integer" + "description": "Property interventionId.", + "type": [ + "string", + "null" + ] }, - "canResendIn": { + "interventionNumber": { "readOnly": true, - "description": "Seconds until OTP can be resent (0 = can resend now)", - "example": 45, - "type": "integer" + "description": "Property interventionNumber.", + "type": [ + "integer", + "null" + ] + }, + "workItemAction": { + "readOnly": true, + "description": "Property workItemAction.", + "type": [ + "string", + "null" + ] + }, + "actorId": { + "readOnly": true, + "description": "Property actorId.", + "type": [ + "string", + "null" + ] + }, + "summary": { + "readOnly": true, + "description": "Property summary.", + "type": [ + "string", + "null" + ] } } } ], - "description": "OTP challenge management for verification and 2FA." - }, - "Challenge.CreateChallengeInput": { - "type": "object", - "description": "OTP challenge management for verification and 2FA.", - "required": [ - "purpose", - "channel" - ], - "properties": { - "purpose": { - "enum": [ - "login", - "password_reset", - "email_verification", - "phone_verification", - "sensitive_operation", - "transaction_approval" - ], - "writeOnly": true, - "description": "The purpose of the OTP challenge. Use GET /api/otp/purposes to list available values.", - "example": "login", - "type": "string" - }, - "channel": { - "enum": [ - "email", - "sms", - "totp" - ], - "writeOnly": true, - "description": "The delivery channel for the OTP. Use GET /api/otp/channels to list available values.", - "example": "email", - "type": "string" - }, - "recipient": { - "writeOnly": true, - "description": "The recipient email or phone number. If not provided, user's default will be used.", - "example": "user@example.com", - "type": "string", - "nullable": true - }, - "ttlSeconds": { - "minimum": 60, - "maximum": 3600, - "writeOnly": true, - "description": "Custom time-to-live in seconds. Defaults to purpose-specific value.", - "example": 300, - "type": "integer", - "nullable": true - }, - "context": { - "writeOnly": true, - "description": "Optional context data (transaction ID, description, etc.)", - "example": { - "transactionId": "TXN-12345", - "description": "Confirm transfer of $100" - }, - "type": "object", - "nullable": true, - "additionalProperties": true - } - } - }, - "Challenge.VerifyOtpInput": { - "type": "object", - "description": "OTP challenge management for verification and 2FA.", - "required": [ - "code" - ], - "properties": { - "code": { - "writeOnly": true, - "description": "The OTP code to verify.", - "example": "123456", - "type": "string" - } - } + "description": "Maintenance windows recorded for equipment items." }, - "Challenge.VerifyOtpOutput.jsonld": { + "EquipmentOpenWork.EquipmentOpenWorkOutput.jsonld": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" }, { "type": "object", - "required": [ - "success", - "attemptsRemaining" - ], "properties": { - "success": { - "readOnly": true, - "description": "Whether verification was successful", - "example": true, - "type": "boolean" + "interventionId": { + "description": "Property interventionId.", + "default": "", + "type": "string" }, - "attemptsRemaining": { - "readOnly": true, - "description": "Remaining verification attempts before the challenge is locked", - "example": 2, + "number": { + "description": "Property number.", + "default": 0, "type": "integer" }, - "error": { - "readOnly": true, - "description": "Error message when verification fails", - "example": "Invalid OTP code", - "type": "string", - "nullable": true + "name": { + "description": "Property name.", + "default": "", + "type": "string" + }, + "status": { + "description": "Property status.", + "default": "", + "type": "string" + }, + "workItemId": { + "description": "Property workItemId.", + "default": "", + "type": "string" + }, + "action": { + "description": "Property action.", + "default": "", + "type": "string" + }, + "workItemStatus": { + "description": "Property workItemStatus.", + "default": "", + "type": "string" } } } ], - "description": "OTP challenge management for verification and 2FA." + "description": "Class EquipmentOpenWorkResource\n\nProvides a scoped lookup without changing equipment-read permissions." }, - "Channel.AddChannelParticipantInput": { + "EquipmentReplacement.ReplaceEquipmentInput-equipment.write": { "type": "object", - "description": "Resource ChannelResource.\n\nNamed group channels — a `Conversation` with `subjectType=CHANNEL`,\n`subjectId=null`, `visibility=PARTICIPANTS`. Every operation requires\n`ROLE_USER` at the resource level; the finer-grained\n`organization.messaging.{read,write,manage}` + participant-membership\nchecks are enforced in the application layer (mirrors\n`ConversationResource`). Messages/subscription/read/edit/delete reuse the\nEXISTING `/api/conversations/{id}/...` endpoints unchanged — a channel id\nIS a conversation id. `PATCH /channels/{id}/parent` (L2.6) sets or clears\na channel's parent, nesting it under another channel — see\n`MessagingChannelHierarchyGuard` for the cycle/cross-organization/\nmax-depth invariants enforced before the write.", + "description": "Class EquipmentReplacementResource\n\nOrganization-scoped atomic equipment replacement, preserving the historical asset.", "required": [ - "memberId" + "clientOperationId" ], "properties": { - "memberId": { + "clientOperationId": { "format": "uuid", - "description": "Property memberId.", + "description": "Stable client-generated replacement operation UUID.", "externalDocs": { "url": "https://schema.org/identifier" }, "default": "", "type": "string" }, - "role": { - "maxLength": 50, - "description": "Property role.", - "type": [ - "string", - "null" - ] - } - } - }, - "Channel.BindChannelTeamInput.jsonMergePatch": { - "type": "object", - "description": "Resource ChannelResource.\n\nNamed group channels — a `Conversation` with `subjectType=CHANNEL`,\n`subjectId=null`, `visibility=PARTICIPANTS`. Every operation requires\n`ROLE_USER` at the resource level; the finer-grained\n`organization.messaging.{read,write,manage}` + participant-membership\nchecks are enforced in the application layer (mirrors\n`ConversationResource`). Messages/subscription/read/edit/delete reuse the\nEXISTING `/api/conversations/{id}/...` endpoints unchanged — a channel id\nIS a conversation id. `PATCH /channels/{id}/parent` (L2.6) sets or clears\na channel's parent, nesting it under another channel — see\n`MessagingChannelHierarchyGuard` for the cycle/cross-organization/\nmax-depth invariants enforced before the write.", - "properties": { - "teamId": { + "successorEquipmentId": { "format": "uuid", - "description": "Property teamId.", + "description": "Existing published equipment in stock, exclusive with successor.", "externalDocs": { "url": "https://schema.org/identifier" }, @@ -48479,10 +62138,21 @@ "string", "null" ] + }, + "successor": { + "description": "New equipment to create in the replacement transaction, exclusive with successorEquipmentId.", + "anyOf": [ + { + "$ref": "#/components/schemas/ReplacementSuccessorInput-equipment.write" + }, + { + "type": "null" + } + ] } } }, - "Channel.ChannelOutput.jsonld": { + "EquipmentReplacement.ReplaceEquipmentOutput.jsonld-equipment.read": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -48490,186 +62160,109 @@ { "type": "object", "properties": { - "id": { - "description": "Property id.", - "default": "", - "type": "string" - }, - "organization": { - "description": "Property organization.", - "default": "", - "type": "string" - }, - "name": { - "description": "Property name.", - "default": "", + "predecessorEquipmentId": { + "description": "Method __construct", "type": "string" }, - "team": { - "description": "Property team.", - "type": [ - "string", - "null" - ] - }, - "createdByMember": { - "description": "Property createdByMember.", - "type": [ - "string", - "null" - ] - }, - "participantCount": { - "description": "Property participantCount.", - "default": 0, - "type": "integer" - }, - "isArchived": { - "description": "Property isArchived.", - "default": false, - "type": "boolean" - }, - "lastMessageAt": { - "description": "Property lastMessageAt.", - "type": [ - "string", - "null" - ] - }, - "messagesCount": { - "description": "Property messagesCount.", - "default": 0, - "type": "integer" - }, - "unreadCount": { - "description": "Property unreadCount.", - "default": 0, - "type": "integer" - }, - "createdAt": { - "description": "Property createdAt.", - "default": "", + "successorEquipmentId": { + "description": "Method __construct", "type": "string" }, - "updatedAt": { - "description": "Property updatedAt.", - "default": "", + "clientOperationId": { + "description": "Method __construct", "type": "string" }, - "isFavorite": { - "description": "Property isFavorite.", - "default": false, + "replayed": { + "description": "Method __construct", "type": "boolean" - }, - "parent": { - "description": "Property parent.", - "type": [ - "string", - "null" - ] } } } ], - "description": "Resource ChannelResource.\n\nNamed group channels — a `Conversation` with `subjectType=CHANNEL`,\n`subjectId=null`, `visibility=PARTICIPANTS`. Every operation requires\n`ROLE_USER` at the resource level; the finer-grained\n`organization.messaging.{read,write,manage}` + participant-membership\nchecks are enforced in the application layer (mirrors\n`ConversationResource`). Messages/subscription/read/edit/delete reuse the\nEXISTING `/api/conversations/{id}/...` endpoints unchanged — a channel id\nIS a conversation id. `PATCH /channels/{id}/parent` (L2.6) sets or clears\na channel's parent, nesting it under another channel — see\n`MessagingChannelHierarchyGuard` for the cycle/cross-organization/\nmax-depth invariants enforced before the write." + "description": "Class EquipmentReplacementResource\n\nOrganization-scoped atomic equipment replacement, preserving the historical asset." }, - "Channel.ChannelParticipantOutput.jsonld": { + "EquipmentTag.AddTagInput-equipment.write": { + "type": "object", + "description": "Tags associated with equipment items.", + "required": [ + "name" + ], + "properties": { + "name": { + "minLength": 1, + "maxLength": 100, + "description": "Tag name (case-insensitive, normalized to lowercase)", + "default": "", + "example": "vérification annuelle", + "type": "string" + } + } + }, + "EquipmentTag.TagOutput.jsonld-equipment.read": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" }, { "type": "object", - "properties": { - "memberId": { - "description": "Property memberId.", + "properties": { + "id": { + "readOnly": true, + "description": "Property id.", "default": "", "type": "string" }, - "role": { - "description": "Property role.", - "type": [ - "string", - "null" - ] - }, - "source": { - "description": "Property source.", + "name": { + "readOnly": true, + "description": "Property name.", "default": "", "type": "string" }, - "addedAt": { - "description": "Property addedAt.", + "organizationId": { + "readOnly": true, + "description": "Property organizationId.", "default": "", "type": "string" } } } ], - "description": "Resource ChannelResource.\n\nNamed group channels — a `Conversation` with `subjectType=CHANNEL`,\n`subjectId=null`, `visibility=PARTICIPANTS`. Every operation requires\n`ROLE_USER` at the resource level; the finer-grained\n`organization.messaging.{read,write,manage}` + participant-membership\nchecks are enforced in the application layer (mirrors\n`ConversationResource`). Messages/subscription/read/edit/delete reuse the\nEXISTING `/api/conversations/{id}/...` endpoints unchanged — a channel id\nIS a conversation id. `PATCH /channels/{id}/parent` (L2.6) sets or clears\na channel's parent, nesting it under another channel — see\n`MessagingChannelHierarchyGuard` for the cycle/cross-organization/\nmax-depth invariants enforced before the write." + "description": "Tags associated with equipment items." }, - "Channel.CreateChannelInput": { + "EquipmentType.CreateEquipmentTypeInput-equipment_type.write": { "type": "object", - "description": "Resource ChannelResource.\n\nNamed group channels — a `Conversation` with `subjectType=CHANNEL`,\n`subjectId=null`, `visibility=PARTICIPANTS`. Every operation requires\n`ROLE_USER` at the resource level; the finer-grained\n`organization.messaging.{read,write,manage}` + participant-membership\nchecks are enforced in the application layer (mirrors\n`ConversationResource`). Messages/subscription/read/edit/delete reuse the\nEXISTING `/api/conversations/{id}/...` endpoints unchanged — a channel id\nIS a conversation id. `PATCH /channels/{id}/parent` (L2.6) sets or clears\na channel's parent, nesting it under another channel — see\n`MessagingChannelHierarchyGuard` for the cycle/cross-organization/\nmax-depth invariants enforced before the write.", + "description": "Resource EquipmentTypeCatalogResource.\n\nOrganization catalogs preserve historical codes while allowing managed additions.", + "additionalProperties": false, "required": [ - "organization", - "name" + "value", + "label" ], "properties": { - "organization": { - "description": "Property organization.", + "value": { + "maxLength": 32, + "description": "Property value.", "default": "", "type": "string" }, - "name": { - "minLength": 2, - "maxLength": 80, - "description": "Property name.", + "label": { + "maxLength": 100, + "description": "Property label.", "default": "", "type": "string" - } - } - }, - "Channel.SetChannelParentInput.jsonMergePatch": { - "type": "object", - "description": "Resource ChannelResource.\n\nNamed group channels — a `Conversation` with `subjectType=CHANNEL`,\n`subjectId=null`, `visibility=PARTICIPANTS`. Every operation requires\n`ROLE_USER` at the resource level; the finer-grained\n`organization.messaging.{read,write,manage}` + participant-membership\nchecks are enforced in the application layer (mirrors\n`ConversationResource`). Messages/subscription/read/edit/delete reuse the\nEXISTING `/api/conversations/{id}/...` endpoints unchanged — a channel id\nIS a conversation id. `PATCH /channels/{id}/parent` (L2.6) sets or clears\na channel's parent, nesting it under another channel — see\n`MessagingChannelHierarchyGuard` for the cycle/cross-organization/\nmax-depth invariants enforced before the write.", - "properties": { - "parentChannelId": { - "format": "uuid", - "description": "Property parentChannelId.", - "externalDocs": { - "url": "https://schema.org/identifier" - }, - "type": [ - "string", - "null" - ] - } - } - }, - "Channel.UpdateChannelInput.jsonMergePatch": { - "type": "object", - "description": "Resource ChannelResource.\n\nNamed group channels — a `Conversation` with `subjectType=CHANNEL`,\n`subjectId=null`, `visibility=PARTICIPANTS`. Every operation requires\n`ROLE_USER` at the resource level; the finer-grained\n`organization.messaging.{read,write,manage}` + participant-membership\nchecks are enforced in the application layer (mirrors\n`ConversationResource`). Messages/subscription/read/edit/delete reuse the\nEXISTING `/api/conversations/{id}/...` endpoints unchanged — a channel id\nIS a conversation id. `PATCH /channels/{id}/parent` (L2.6) sets or clears\na channel's parent, nesting it under another channel — see\n`MessagingChannelHierarchyGuard` for the cycle/cross-organization/\nmax-depth invariants enforced before the write.", - "properties": { - "name": { - "minLength": 2, - "maxLength": 80, - "description": "Property name.", - "type": [ - "string", - "null" - ] }, - "isArchived": { - "description": "Property isArchived.", - "type": [ - "boolean", - "null" - ] + "family": { + "enum": [ + "fire", + "safety", + "other" + ], + "description": "Property family.", + "default": "fire", + "type": "string" } } }, - "Checklist.ChecklistOutput.jsonld-inspection.read": { + "EquipmentType.EquipmentTypeOutput.jsonld-equipment_type.read": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -48677,684 +62270,362 @@ { "type": "object", "properties": { - "id": { - "readOnly": true, - "description": "Property id", - "default": "", - "type": "string" - }, - "organizationId": { - "readOnly": true, - "description": "Property organizationId", - "default": "", - "type": "string" - }, - "name": { - "readOnly": true, - "description": "Property name", - "default": "", - "type": "string" - }, - "referenceCode": { - "readOnly": true, - "description": "Property referenceCode", - "type": [ - "string", - "null" - ] - }, - "version": { - "readOnly": true, - "description": "Property version", - "default": "", - "type": "string" - }, - "status": { - "readOnly": true, - "description": "Property status", + "value": { + "description": "Property value.", "default": "", "type": "string" }, - "itemCount": { - "readOnly": true, - "description": "Property itemCount", - "default": 0, - "type": "integer" - }, - "items": { - "readOnly": true, - "description": "Property items", - "type": "array", - "items": { - "$ref": "#/components/schemas/ChecklistItemOutput.jsonld-inspection.read" - } - }, - "createdAt": { - "readOnly": true, - "description": "Property createdAt", + "label": { + "description": "Property label.", "default": "", "type": "string" }, - "updatedAt": { - "readOnly": true, - "description": "Property updatedAt", - "default": "", + "family": { + "description": "Property family.", + "default": "other", "type": "string" }, - "previousChecklistId": { - "description": "Property previousChecklistId", - "type": [ - "string", - "null" - ] - }, - "canEditMetadata": { - "description": "Property canEditMetadata", - "default": false, - "type": "boolean" - }, - "canEditItems": { - "description": "Property canEditItems", + "archived": { + "description": "Property archived.", "default": false, "type": "boolean" }, - "canCreateRevision": { - "description": "Property canCreateRevision", - "default": false, - "type": "boolean" + "revision": { + "description": "Property revision.", + "default": 1, + "type": "integer" } } } ], - "description": "Versioned checklist templates for inspections." + "description": "Resource EquipmentTypeCatalogResource.\n\nOrganization catalogs preserve historical codes while allowing managed additions." }, - "Checklist.CreateChecklistInput-inspection.write": { + "EquipmentType.PatchEquipmentTypeInput-equipment_type.write.jsonMergePatch": { "type": "object", - "description": "Versioned checklist templates for inspections.", - "required": [ - "name", - "version" - ], + "description": "Resource EquipmentTypeCatalogResource.\n\nOrganization catalogs preserve historical codes while allowing managed additions.", + "additionalProperties": false, "properties": { - "name": { - "maxLength": 255, - "description": "Checklist name", - "default": "", - "example": "Contrôle Extincteur", - "type": "string" - }, - "version": { - "maxLength": 50, - "description": "Version label", - "default": "", - "example": "1.0", - "type": "string" + "revision": { + "minimum": 0, + "exclusiveMinimum": 0, + "description": "Property revision.", + "default": 0, + "type": "integer" }, - "referenceCode": { - "maxLength": 40, - "description": "Optional human-facing reference code, unique per organization", - "example": "CHK-EXT-Q", + "label": { + "maxLength": 100, + "description": "Property label.", "type": [ "string", "null" ] }, - "previousChecklistId": { - "format": "uuid", - "description": "Previous checklist in the same organization. Creates a distinct revision without changing existing inspections.", - "externalDocs": { - "url": "https://schema.org/identifier" - }, + "family": { + "enum": [ + "fire", + "safety", + "other" + ], + "description": "Property family.", "type": [ "string", "null" ] }, - "items": { - "description": "Checklist items", - "type": "array", - "items": { - "$ref": "#/components/schemas/ChecklistItemInput-inspection.write" - } + "archived": { + "description": "Property archived.", + "type": [ + "boolean", + "null" + ] } } }, - "Checklist.UpdateChecklistInput-inspection.write.jsonMergePatch": { + "Error": { "type": "object", - "description": "Versioned checklist templates for inspections.", + "description": "A representation of common errors.", "properties": { - "name": { - "maxLength": 255, - "description": "Checklist name", - "example": "Contrôle Extincteur", + "title": { + "readOnly": true, + "description": "A short, human-readable summary of the problem.", "type": [ "string", "null" ] }, - "referenceCode": { - "maxLength": 40, - "description": "Optional human-facing reference code, unique per organization", - "example": "CHK-EXT-Q", + "detail": { + "readOnly": true, + "description": "A human-readable explanation specific to this occurrence of the problem.", "type": [ "string", "null" ] }, - "items": { - "description": "Checklist items (full replacement list)", - "type": "array", - "items": { - "$ref": "#/components/schemas/ChecklistItemInput-inspection.write" - } - } - } - }, - "ChecklistItemInput-inspection.write": { - "type": "object", - "required": [ - "label" - ], - "properties": { - "label": { - "maxLength": 255, - "description": "Item label", - "default": "", - "example": "Vérifier la pression", - "type": "string" - }, - "description": { - "maxLength": 1000, - "description": "Optional item description", + "status": { "type": [ - "string", + "integer", "null" - ] - }, - "required": { - "description": "Whether the item is required", - "default": true, - "example": true, - "type": "boolean" - }, - "position": { - "minimum": 0, - "description": "Display position", - "default": 0, - "example": 0, - "type": "integer" - } - } - }, - "ChecklistItemOutput.jsonld-inspection.read": { - "type": "object", - "properties": { - "id": { - "readOnly": true, - "description": "Property id.", - "default": "", - "type": "string" - }, - "label": { - "readOnly": true, - "description": "Property label.", - "default": "", - "type": "string" - }, - "position": { - "readOnly": true, - "description": "Property position.", - "default": 0, - "type": "integer" - }, - "required": { - "readOnly": true, - "description": "Property required.", - "default": true, - "type": "boolean" + ], + "examples": [ + 404 + ], + "default": 400 }, - "description": { + "instance": { "readOnly": true, - "description": "Property description.", + "description": "A URI reference that identifies the specific occurrence of the problem. It may or may not yield further information if dereferenced.", "type": [ "string", "null" ] + }, + "type": { + "readOnly": true, + "description": "A URI reference that identifies the problem type", + "type": "string" } - } - }, - "Client.ClientInput-client.update.jsonMergePatch": { - "type": "object", - "description": "Updates name, redirect URIs, grant types, or scopes of an existing client.", - "properties": { - "client_name": { - "minLength": 3, - "maxLength": 100, - "writeOnly": true, - "description": "Human-readable name of the client application", - "example": "My Awesome App", - "type": "string" - }, - "redirect_uris": { - "writeOnly": true, - "description": "List of allowed redirect URIs for OAuth2 callbacks", - "example": [ - "https://app.example.com/callback" - ], - "type": "array", - "items": { - "type": "string", - "format": "uri" - } - }, - "grant_types": { - "writeOnly": true, - "description": "List of allowed OAuth2 grant types", - "example": [ - "authorization_code", - "refresh_token" - ], - "type": "array", - "items": { - "type": "string", - "enum": [ - "authorization_code", - "client_credentials", - "refresh_token" - ] - } - }, - "scopes": { - "writeOnly": true, - "description": "List of allowed OAuth2 scopes", - "example": [ - "openid", - "profile", - "email" - ], - "type": "array", - "items": { - "type": "string" - } - } - } - }, - "Client.ClientInput-client.write": { - "type": "object", - "description": "Register a new OAuth2 client application. The client secret is shown only once.", - "required": [ - "client_name", - "redirect_uris", - "grant_types", - "scopes" - ], - "properties": { - "client_name": { - "minLength": 3, - "maxLength": 100, - "writeOnly": true, - "description": "Human-readable name of the client application", - "example": "My Awesome App", - "type": "string" - }, - "redirect_uris": { - "writeOnly": true, - "description": "List of allowed redirect URIs for OAuth2 callbacks", - "example": [ - "https://app.example.com/callback" - ], - "type": "array", - "items": { - "type": "string", - "format": "uri" - } - }, - "grant_types": { - "writeOnly": true, - "description": "List of allowed OAuth2 grant types", - "example": [ - "authorization_code", - "refresh_token" - ], - "type": "array", - "items": { - "type": "string", - "enum": [ - "authorization_code", - "client_credentials", - "refresh_token" - ] - } - }, - "scopes": { - "writeOnly": true, - "description": "List of allowed OAuth2 scopes", - "example": [ - "openid", - "profile", - "email" - ], - "type": "array", - "items": { - "type": "string" - } - } - } - }, - "Client.ClientOutput.jsonld-client.read": { - "allOf": [ - { - "$ref": "#/components/schemas/HydraItemBaseSchema" - }, - { - "type": "object", - "required": [ - "client_id", - "is_active", - "created_at" - ], - "properties": { - "client_id": { - "readOnly": true, - "description": "Unique client identifier (UUID)", - "example": "01234567-89ab-cdef-0123-456789abcdef", - "type": "string", - "format": "uuid" - }, - "client_name": { - "readOnly": true, - "description": "Human-readable name of the client", - "example": "My Awesome App", - "type": "string" - }, - "redirect_uris": { - "readOnly": true, - "description": "List of registered redirect URIs", - "example": [ - "https://app.example.com/callback" - ], - "type": "array", - "items": { - "type": "string", - "format": "uri" - } - }, - "grant_types": { - "readOnly": true, - "description": "List of allowed grant types", - "example": [ - "authorization_code" - ], - "type": "array", - "items": { - "type": "string" - } - }, - "scopes": { - "readOnly": true, - "description": "List of allowed scopes", - "example": [ - "openid", - "profile" - ], - "type": "array", - "items": { - "type": "string" - } - }, - "is_active": { - "readOnly": true, - "description": "Whether the client is currently active", - "default": true, - "example": true, - "type": "boolean" - }, - "created_at": { - "readOnly": true, - "description": "Creation timestamp (ISO 8601)", - "example": "2025-01-01T12:00:00+00:00", - "type": "string", - "format": "date-time" - } - } - } - ], - "description": "Get details of a specific OAuth2 client. Secret is not included." + } }, - "Client.ClientOutput.jsonld-client.read_client.secret": { + "Error.jsonld": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" }, { "type": "object", - "required": [ - "client_id", - "is_active", - "created_at" - ], "properties": { - "client_id": { - "readOnly": true, - "description": "Unique client identifier (UUID)", - "example": "01234567-89ab-cdef-0123-456789abcdef", - "type": "string", - "format": "uuid" - }, - "client_name": { - "readOnly": true, - "description": "Human-readable name of the client", - "example": "My Awesome App", - "type": "string" - }, - "client_secret": { + "title": { "readOnly": true, - "description": "Client secret (only shown once)", - "example": "your-client-secret", - "type": "string" + "description": "A short, human-readable summary of the problem.", + "type": [ + "string", + "null" + ] }, - "redirect_uris": { + "detail": { "readOnly": true, - "description": "List of registered redirect URIs", - "example": [ - "https://app.example.com/callback" - ], - "type": "array", - "items": { - "type": "string", - "format": "uri" - } + "description": "A human-readable explanation specific to this occurrence of the problem.", + "type": [ + "string", + "null" + ] }, - "grant_types": { - "readOnly": true, - "description": "List of allowed grant types", - "example": [ - "authorization_code" + "status": { + "type": [ + "integer", + "null" ], - "type": "array", - "items": { - "type": "string" - } - }, - "scopes": { - "readOnly": true, - "description": "List of allowed scopes", - "example": [ - "openid", - "profile" + "examples": [ + 404 ], - "type": "array", - "items": { - "type": "string" - } - }, - "is_active": { - "readOnly": true, - "description": "Whether the client is currently active", - "default": true, - "example": true, - "type": "boolean" + "default": 400 }, - "created_at": { + "instance": { "readOnly": true, - "description": "Creation timestamp (ISO 8601)", - "example": "2025-01-01T12:00:00+00:00", - "type": "string", - "format": "date-time" - } - } - } - ], - "description": "Register a new OAuth2 client application. The client secret is shown only once." - }, - "ComplianceSummary.ComplianceSummaryOutput.jsonld": { - "allOf": [ - { - "$ref": "#/components/schemas/HydraItemBaseSchema" - }, - { - "type": "object", - "properties": { - "organizationId": { - "description": "Property organizationId.", - "default": "", - "type": "string" - }, - "facilityId": { - "description": "Property facilityId.", + "description": "A URI reference that identifies the specific occurrence of the problem. It may or may not yield further information if dereferenced.", "type": [ "string", "null" ] }, - "generatedAt": { - "description": "Property generatedAt.", - "default": "", + "type": { + "readOnly": true, + "description": "A URI reference that identifies the problem type", "type": "string" }, - "dataEvaluatedAt": { - "description": "Oldest successful maintenance evaluation in the scope, distinct from generation time.", + "description": { + "readOnly": true, "type": [ "string", "null" ] - }, - "organizationStatus": { - "description": "Property organizationStatus.", - "default": "", - "type": "string" - }, - "totals": { - "description": "Property totals.", - "type": "object", - "additionalProperties": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "integer" - }, - { - "type": "null" - } - ] - } - }, - "facilities": { - "description": "Property facilities.", - "type": "array", - "items": { - "type": "object", - "additionalProperties": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "integer" - }, - { - "type": "string" - }, - { - "type": "null" - } - ] - } - } } } } ], - "description": "Regulatory compliance register summary: organization rollup and per-facility breakdown, graded compliant/at_risk/non_compliant/not_applicable from Maintenance due-status, Inspection non-conformity, and Equipment inventory counts. Requires organization.compliance.read." + "description": "A representation of common errors." }, - "ConstraintViolation": { + "Facility.CreateFacilityInput": { "type": "object", - "description": "Unprocessable entity", + "description": "Resource CanonicalFacilityResource.", "properties": { - "status": { - "default": 422, - "type": "integer" + "clientId": { + "format": "uuid", + "description": "Client-generated UUID for idempotent offline creation", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "type": [ + "string", + "null" + ] }, - "violations": { - "type": "array", - "items": { - "type": "object", - "properties": { - "propertyPath": { - "type": "string", - "description": "The property path of the violation" - }, - "message": { - "type": "string", - "description": "The message associated with the violation" - }, - "code": { - "type": "string", - "description": "The code of the violation" - }, - "hint": { - "type": "string", - "description": "An extra hint to understand the violation" - }, - "payload": { - "type": "object", - "additionalProperties": true, - "description": "The serialized payload of the violation" - } - }, - "required": [ - "propertyPath", - "message" - ] - } + "organization": { + "pattern": "^(/api/organizations/[0-9a-fA-F-]{36})$", + "description": "Organization IRI, required on the canonical endpoint", + "type": [ + "string", + "null" + ] }, - "detail": { - "readOnly": true, - "type": "string" + "intervention": { + "pattern": "^(/api/interventions/[0-9a-fA-F-]{36})$", + "description": "Optional field intervention IRI", + "type": [ + "string", + "null" + ] + }, + "onboardingSessionId": { + "format": "uuid", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "type": [ + "string", + "null" + ] + }, + "onboardingItemKey": { + "type": [ + "string", + "null" + ] }, "type": { - "readOnly": true, + "enum": [ + "site", + "building", + "floor", + "zone", + "area" + ], + "description": "Facility type", + "default": "", + "example": "site", "type": "string" }, - "title": { - "readOnly": true, + "name": { + "minLength": 2, + "maxLength": 120, + "description": "Facility display name", + "default": "", + "example": "Headquarters", + "type": "string" + }, + "parentFacilityId": { + "format": "uuid", + "description": "Optional parent facility identifier", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "example": "550e8400-e29b-41d4-a716-446655440001", "type": [ "string", "null" ] }, - "instance": { - "readOnly": true, + "code": { + "maxLength": 80, + "description": "Optional facility code", + "example": "SITE-PAR-001", + "type": [ + "string", + "null" + ] + }, + "address": { + "maxLength": 255, + "description": "Optional address", + "example": "10 rue de Rivoli, Paris", + "type": [ + "string", + "null" + ] + }, + "latitude": { + "minimum": -90, + "maximum": 90, + "description": "Optional latitude, required together with longitude", + "example": 48.8566, + "type": [ + "number", + "null" + ] + }, + "longitude": { + "minimum": -180, + "maximum": 180, + "description": "Optional longitude, required together with latitude", + "example": 2.3522, + "type": [ + "number", + "null" + ] + }, + "metadata": { + "description": "Optional free-form metadata", + "example": { + "country": "FR", + "timezone": "Europe/Paris" + }, + "type": "object", + "additionalProperties": { + "type": [ + "string", + "null" + ] + } + }, + "levelIndex": { + "minimum": -100, + "maximum": 200, + "description": "Optional stacking order of the floor (ground floor = 0, first basement = -1)", + "example": 0, + "type": [ + "integer", + "null" + ] + }, + "elevationMeters": { + "minimum": -10000, + "maximum": 10000, + "description": "Optional floor elevation in meters", + "example": -3, + "type": [ + "number", + "null" + ] + }, + "heightMeters": { + "minimum": 0, + "exclusiveMinimum": 0, + "maximum": 1000, + "description": "Optional floor height in meters", + "example": 3, + "type": [ + "number", + "null" + ] + }, + "customerId": { + "format": "uuid", + "description": "Internal customer identifier for a root site", + "externalDocs": { + "url": "https://schema.org/identifier" + }, "type": [ "string", "null" ] } - } + }, + "required": [ + "type", + "name" + ] }, - "ConstraintViolation.jsonld": { + "Facility.FacilityOutput.jsonld": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -49362,123 +62633,377 @@ { "type": "object", "properties": { - "status": { - "default": 422, + "intervention": { + "description": "Property intervention.", + "type": [ + "string", + "null" + ] + }, + "recordStatus": { + "description": "Property recordStatus.", + "default": "published", + "type": "string" + }, + "revision": { + "description": "Property revision.", + "default": 1, "type": "integer" }, - "violations": { - "type": "array", - "items": { - "type": "object", - "properties": { - "propertyPath": { - "type": "string", - "description": "The property path of the violation" - }, - "message": { - "type": "string", - "description": "The message associated with the violation" - }, - "code": { - "type": "string", - "description": "The code of the violation" - }, - "hint": { - "type": "string", - "description": "An extra hint to understand the violation" - }, - "payload": { - "type": "object", - "additionalProperties": true, - "description": "The serialized payload of the violation" - } - }, - "required": [ - "propertyPath", - "message" + "id": { + "readOnly": true, + "description": "Property id.", + "default": "", + "type": "string" + }, + "organizationId": { + "readOnly": true, + "description": "Property organizationId.", + "default": "", + "type": "string" + }, + "parentFacilityId": { + "readOnly": true, + "description": "Property parentFacilityId.", + "type": [ + "string", + "null" + ] + }, + "hasChildren": { + "readOnly": true, + "description": "Property hasChildren.", + "default": false, + "type": "boolean" + }, + "equipmentCount": { + "readOnly": true, + "description": "Active equipment assigned to this facility", + "default": 0, + "type": "integer" + }, + "type": { + "readOnly": true, + "description": "Property type.", + "default": "", + "type": "string" + }, + "name": { + "readOnly": true, + "description": "Property name.", + "default": "", + "type": "string" + }, + "code": { + "readOnly": true, + "description": "Property code.", + "type": [ + "string", + "null" + ] + }, + "status": { + "readOnly": true, + "description": "Property status.", + "default": "", + "type": "string" + }, + "address": { + "readOnly": true, + "description": "Property address.", + "type": [ + "string", + "null" + ] + }, + "latitude": { + "readOnly": true, + "description": "Property latitude.", + "type": [ + "number", + "null" + ] + }, + "longitude": { + "readOnly": true, + "description": "Property longitude.", + "type": [ + "number", + "null" + ] + }, + "metadata": { + "readOnly": true, + "description": "Property metadata.", + "type": "object", + "additionalProperties": { + "type": [ + "string", + "null" ] } }, - "detail": { + "levelIndex": { "readOnly": true, - "type": "string" + "description": "Optional stacking order of the floor (ground floor = 0, first basement = -1)", + "type": [ + "integer", + "null" + ] }, - "description": { + "elevationMeters": { "readOnly": true, - "type": "string" + "description": "Property elevationMeters", + "type": [ + "number", + "null" + ] }, - "type": { + "heightMeters": { "readOnly": true, - "type": "string" + "description": "Property heightMeters", + "type": [ + "number", + "null" + ] }, - "title": { - "readOnly": true, + "customerId": { + "description": "Optional internal customer identifier on root sites", "type": [ "string", "null" ] }, - "instance": { + "planGeometry": { + "readOnly": true, + "description": "Optional spatial geometry bound to an ancestor floor plan (detail views only)", + "type": [ + "object", + "null" + ], + "additionalProperties": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "number" + } + } + }, + { + "type": "string" + } + ] + } + }, + "geometryIssue": { "readOnly": true, + "description": "Property geometryIssue.", "type": [ "string", "null" + ], + "enum": [ + "invalid_geometry", + "plan_unavailable", + "outside_ancestry", + "other_plan", + null ] + }, + "hierarchyIssues": { + "readOnly": true, + "description": "Property hierarchyIssues.", + "type": "array", + "items": { + "type": "string" + } + }, + "createdAt": { + "readOnly": true, + "description": "Property createdAt.", + "default": "", + "type": "string" + }, + "updatedAt": { + "readOnly": true, + "description": "Property updatedAt.", + "default": "", + "type": "string" + }, + "path": { + "readOnly": true, + "description": "Ancestor breadcrumb, root first, excluding this facility; opt-in on collections through includePath.", + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "name", + "type" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "site", + "building", + "floor", + "zone", + "area" + ] + } + } + } } } } ], - "description": "Unprocessable entity" - }, - "Conversation.AcknowledgeDeliveryInput": { - "type": "object", - "description": "Resource ConversationResource.\n\nContextual discussion threads bound one-to-one to a core-entity subject\n(facility | equipment | intervention | non-conformity in v1). Every\noperation requires `ROLE_USER` at the resource level; the finer-grained\n`organization.messaging.{read,write,manage}` + subject-permission checks\nare enforced in the application layer (mirrors Maintenance/Intervention).", - "required": [ - "messageId" - ], - "properties": { - "messageId": { - "description": "Property messageId", - "default": "", - "type": "string" - } - } + "description": "Resource CanonicalFacilityResource." }, - "Conversation.ArchiveConversationInput.jsonMergePatch": { + "Facility.PatchCanonicalFacilityInput.jsonMergePatch": { "type": "object", - "description": "Resource ConversationResource.\n\nContextual discussion threads bound one-to-one to a core-entity subject\n(facility | equipment | intervention | non-conformity in v1). Every\noperation requires `ROLE_USER` at the resource level; the finer-grained\n`organization.messaging.{read,write,manage}` + subject-permission checks\nare enforced in the application layer (mirrors Maintenance/Intervention).", + "description": "Resource CanonicalFacilityResource.", "properties": { - "isArchived": { - "description": "Property isArchived.", - "default": true, - "type": "boolean" - } - } - }, - "Conversation.ConversationActivityBucketOutput.jsonld": { - "allOf": [ - { - "$ref": "#/components/schemas/HydraItemBaseSchema" + "type": { + "enum": [ + "site", + "building", + "floor", + "zone", + "area" + ], + "description": "Property type.", + "type": [ + "string", + "null" + ] }, - { - "type": "object", - "properties": { - "bucket": { - "description": "Property bucket.", - "default": "", - "type": "string" - }, - "count": { - "description": "Property count.", - "default": 0, - "type": "integer" - } + "name": { + "minLength": 2, + "maxLength": 120, + "description": "Property name.", + "type": [ + "string", + "null" + ] + }, + "code": { + "maxLength": 80, + "description": "Property code.", + "type": [ + "string", + "null" + ] + }, + "customerId": { + "format": "uuid", + "description": "Property customerId. Optional internal customer identifier for a root site.", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "type": [ + "string", + "null" + ] + }, + "address": { + "maxLength": 255, + "description": "Property address.", + "type": [ + "string", + "null" + ] + }, + "latitude": { + "minimum": -90, + "maximum": 90, + "description": "Property latitude.", + "type": [ + "number", + "null" + ] + }, + "longitude": { + "minimum": -180, + "maximum": 180, + "description": "Property longitude.", + "type": [ + "number", + "null" + ] + }, + "metadata": { + "description": "Property metadata.", + "type": [ + "object", + "null" + ], + "additionalProperties": { + "type": [ + "string", + "null" + ] } + }, + "status": { + "enum": [ + "active", + "archived" + ], + "description": "Property status.", + "type": [ + "string", + "null" + ] + }, + "parent": { + "description": "Property parent.", + "type": [ + "string", + "null" + ] + }, + "levelIndex": { + "minimum": -100, + "maximum": 200, + "description": "Property levelIndex.", + "type": [ + "integer", + "null" + ] + }, + "elevationMeters": { + "minimum": -10000, + "maximum": 10000, + "description": "Property elevationMeters", + "type": [ + "number", + "null" + ] + }, + "heightMeters": { + "minimum": 0, + "exclusiveMinimum": 0, + "maximum": 1000, + "description": "Property heightMeters", + "type": [ + "number", + "null" + ] } - ], - "description": "Resource ConversationResource.\n\nContextual discussion threads bound one-to-one to a core-entity subject\n(facility | equipment | intervention | non-conformity in v1). Every\noperation requires `ROLE_USER` at the resource level; the finer-grained\n`organization.messaging.{read,write,manage}` + subject-permission checks\nare enforced in the application layer (mirrors Maintenance/Intervention)." + } }, - "Conversation.ConversationOutput.jsonld": { + "FacilityAttachment.FacilityAttachmentOutput.jsonld-facility.read": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -49487,191 +63012,148 @@ "type": "object", "properties": { "id": { + "readOnly": true, "description": "Property id.", "default": "", "type": "string" }, - "organization": { - "description": "Property organization.", + "facilityId": { + "readOnly": true, + "description": "Property facilityId.", "default": "", "type": "string" }, - "subjectType": { - "description": "Property subjectType.", + "fileName": { + "readOnly": true, + "description": "Property fileName.", "default": "", "type": "string" }, - "subject": { - "description": "Property subject.", - "type": [ - "string", - "null" - ] - }, - "subjectLabel": { - "description": "Property subjectLabel.", - "type": [ - "string", - "null" - ] - }, - "visibility": { - "description": "Property visibility.", + "mimeType": { + "readOnly": true, + "description": "Property mimeType.", "default": "", "type": "string" }, - "lastMessageAt": { - "description": "Property lastMessageAt.", + "size": { + "readOnly": true, + "description": "Property size.", + "default": 0, + "type": "integer" + }, + "label": { + "readOnly": true, + "description": "Property label.", "type": [ "string", "null" ] }, - "messagesCount": { - "description": "Property messagesCount.", - "default": 0, - "type": "integer" - }, - "isArchived": { - "description": "Property isArchived.", - "default": false, - "type": "boolean" - }, - "unreadCount": { - "description": "Property unreadCount.", - "default": 0, + "revision": { + "readOnly": true, + "description": "Property revision.", + "default": 1, "type": "integer" }, - "createdAt": { - "description": "Property createdAt.", - "default": "", - "type": "string" - }, - "updatedAt": { - "description": "Property updatedAt.", - "default": "", + "kind": { + "readOnly": true, + "description": "Property kind.", + "default": "document", "type": "string" }, - "isChannel": { - "description": "Property isChannel.", + "isPrimaryPlan": { + "readOnly": true, + "description": "Property isPrimaryPlan.", "default": false, "type": "boolean" }, - "name": { - "description": "Property name.", + "imageWidth": { + "readOnly": true, + "description": "Property imageWidth.", "type": [ - "string", + "integer", "null" ] }, - "team": { - "description": "Property team.", + "imageHeight": { + "readOnly": true, + "description": "Property imageHeight.", "type": [ - "string", + "integer", "null" ] }, - "isFavorite": { - "description": "Property isFavorite.", - "default": false, - "type": "boolean" + "uploadedAt": { + "readOnly": true, + "description": "Property uploadedAt.", + "default": "", + "type": "string" }, - "parentConversationId": { - "description": "Property parentConversationId.", + "calibration": { + "readOnly": true, + "description": "Metric calibration of the original plan image.", "type": [ - "string", + "object", "null" - ] + ], + "required": [ + "widthMeters", + "rotationDegrees", + "offsetXMeters", + "offsetZMeters" + ], + "properties": { + "widthMeters": { + "type": "number", + "exclusiveMinimum": 0, + "maximum": 100000 + }, + "rotationDegrees": { + "type": "number", + "minimum": -360, + "maximum": 360 + }, + "offsetXMeters": { + "type": "number", + "minimum": -100000, + "maximum": 100000 + }, + "offsetZMeters": { + "type": "number", + "minimum": -100000, + "maximum": 100000 + } + }, + "additionalProperties": false }, - "counterpartMember": { - "description": "Property counterpartMember.", + "calibrationBuildingId": { + "readOnly": true, + "description": "Property calibrationBuildingId", "type": [ "string", "null" - ] - } - } - } - ], - "description": "Resource ConversationResource.\n\nContextual discussion threads bound one-to-one to a core-entity subject\n(facility | equipment | intervention | non-conformity in v1). Every\noperation requires `ROLE_USER` at the resource level; the finer-grained\n`organization.messaging.{read,write,manage}` + subject-permission checks\nare enforced in the application layer (mirrors Maintenance/Intervention)." - }, - "Conversation.ConversationReceiptsOutput.jsonld": { - "allOf": [ - { - "$ref": "#/components/schemas/HydraItemBaseSchema" - }, - { - "type": "object", - "properties": { - "receipts": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ConversationReceiptPositionOutput.jsonld" - } - } - } - } - ], - "description": "Resource ConversationResource.\n\nContextual discussion threads bound one-to-one to a core-entity subject\n(facility | equipment | intervention | non-conformity in v1). Every\noperation requires `ROLE_USER` at the resource level; the finer-grained\n`organization.messaging.{read,write,manage}` + subject-permission checks\nare enforced in the application layer (mirrors Maintenance/Intervention)." - }, - "Conversation.ConversationSignalOutput.jsonld": { - "allOf": [ - { - "$ref": "#/components/schemas/HydraItemBaseSchema" - }, - { - "type": "object", - "properties": { - "accepted": { - "description": "Property accepted", - "default": true, - "type": "boolean" - } - } - } - ], - "description": "Resource ConversationResource.\n\nContextual discussion threads bound one-to-one to a core-entity subject\n(facility | equipment | intervention | non-conformity in v1). Every\noperation requires `ROLE_USER` at the resource level; the finer-grained\n`organization.messaging.{read,write,manage}` + subject-permission checks\nare enforced in the application layer (mirrors Maintenance/Intervention)." - }, - "Conversation.GetOrCreateConversationInput": { - "type": "object", - "description": "Resource ConversationResource.\n\nContextual discussion threads bound one-to-one to a core-entity subject\n(facility | equipment | intervention | non-conformity in v1). Every\noperation requires `ROLE_USER` at the resource level; the finer-grained\n`organization.messaging.{read,write,manage}` + subject-permission checks\nare enforced in the application layer (mirrors Maintenance/Intervention).", - "required": [ - "organization", - "subjectType", - "subject" - ], - "properties": { - "organization": { - "description": "Property organization.", - "default": "", - "type": "string" - }, - "subjectType": { - "description": "Property subjectType.", - "default": "", - "type": "string" - }, - "subject": { - "description": "Property subject.", - "default": "", - "type": "string" - } - } - }, - "Conversation.MarkConversationReadInput.jsonMergePatch": { - "type": "object", - "description": "Resource ConversationResource.\n\nContextual discussion threads bound one-to-one to a core-entity subject\n(facility | equipment | intervention | non-conformity in v1). Every\noperation requires `ROLE_USER` at the resource level; the finer-grained\n`organization.messaging.{read,write,manage}` + subject-permission checks\nare enforced in the application layer (mirrors Maintenance/Intervention).", - "properties": { - "lastReadMessageId": { - "description": "Property lastReadMessageId.", - "type": [ - "string", - "null" - ] + ], + "format": "uuid" + }, + "calibrationIssue": { + "readOnly": true, + "description": "Property calibrationIssue", + "type": [ + "string", + "null" + ], + "enum": [ + "building_changed", + "unverified_frame", + null + ] + } + } } - } + ], + "description": "File attachments linked to a facility." }, - "Conversation.MessagingSubscriptionOutput.jsonld": { + "FacilityEquipmentSummary.FacilityEquipmentSummaryOutput.jsonld": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -49679,65 +63161,144 @@ { "type": "object", "properties": { - "token": { + "scope": { "readOnly": true, - "description": "Property token.", - "default": "", - "type": "string" + "description": "Property scope.", + "default": "subtree", + "type": "string", + "enum": [ + "subtree", + "direct" + ] }, - "topic": { + "totalItems": { "readOnly": true, - "description": "Property topic.", - "default": "", - "type": "string" + "description": "Property totalItems.", + "default": 0, + "type": "integer", + "minimum": 0 + }, + "byStatus": { + "readOnly": true, + "description": "Property byStatus.", + "default": { + "in_stock": 0, + "operational": 0, + "under_maintenance": 0, + "decommissioned": 0 + }, + "type": "object", + "required": [ + "in_stock", + "operational", + "under_maintenance", + "decommissioned" + ], + "properties": { + "in_stock": { + "type": "integer", + "minimum": 0 + }, + "operational": { + "type": "integer", + "minimum": 0 + }, + "under_maintenance": { + "type": "integer", + "minimum": 0 + }, + "decommissioned": { + "type": "integer", + "minimum": 0 + } + } + }, + "needingAttentionCount": { + "readOnly": true, + "description": "Property needingAttentionCount.", + "default": 0, + "type": "integer", + "minimum": 0 } } } ], - "description": "Resource ConversationResource.\n\nContextual discussion threads bound one-to-one to a core-entity subject\n(facility | equipment | intervention | non-conformity in v1). Every\noperation requires `ROLE_USER` at the resource level; the finer-grained\n`organization.messaging.{read,write,manage}` + subject-permission checks\nare enforced in the application layer (mirrors Maintenance/Intervention)." - }, - "Conversation.PublishTypingInput": { - "type": "object", - "description": "Resource ConversationResource.\n\nContextual discussion threads bound one-to-one to a core-entity subject\n(facility | equipment | intervention | non-conformity in v1). Every\noperation requires `ROLE_USER` at the resource level; the finer-grained\n`organization.messaging.{read,write,manage}` + subject-permission checks\nare enforced in the application layer (mirrors Maintenance/Intervention).", - "properties": { - "active": { - "description": "Property active", - "default": false, - "type": "boolean" - } - } + "description": "Resource FacilityEquipmentSummaryResource." }, - "ConversationReceiptPositionOutput.jsonld": { + "FacilityMetadataField.CreateFacilityMetadataFieldInput-facility.write": { "type": "object", + "description": "Organization-defined typed schema for facility metadata.", + "required": [ + "key", + "label", + "fieldType" + ], "properties": { - "memberId": { - "description": "Property memberId", + "key": { + "minLength": 2, + "maxLength": 64, + "description": "Machine key, kebab-case or snake_case", "default": "", + "example": "surface-m2", "type": "string" }, - "deliveredMessageId": { - "description": "Property deliveredMessageId", - "type": [ - "string", - "null" - ] + "label": { + "minLength": 2, + "maxLength": 80, + "description": "Human-readable label", + "default": "", + "example": "Surface (m²)", + "type": "string" }, - "deliveredThroughAt": { - "description": "Property deliveredThroughAt", - "type": [ - "string", - "null" - ] + "fieldType": { + "enum": [ + "text", + "number", + "date", + "boolean", + "select" + ], + "description": "Field type", + "default": "", + "example": "number", + "type": "string" }, - "readMessageId": { - "description": "Property readMessageId", + "required": { + "description": "Whether the field is required on facility creation", + "default": false, + "type": "boolean" + }, + "options": { + "description": "Select options, required only when fieldType is \"select\"", + "example": [ + "ERP", + "IGH", + "Habitation" + ], + "type": "array", + "items": { + "type": "string" + } + }, + "facilityType": { + "enum": [ + "site", + "building", + "floor", + "zone", + "area" + ], + "description": "Optional facility type scope; omitted or null applies to every type", + "example": "building", "type": [ "string", "null" ] }, - "readThroughAt": { - "description": "Property readThroughAt", + "unit": { + "maxLength": 16, + "description": "Optional unit label", + "example": "m²", "type": [ "string", "null" @@ -49745,7 +63306,7 @@ } } }, - "CurrentOrganizationMemberProfile.CurrentOrganizationMemberProfileOutput.jsonld-Organization.read": { + "FacilityMetadataField.FacilityMetadataFieldOutput.jsonld-facility.read": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -49755,86 +63316,131 @@ "properties": { "id": { "readOnly": true, - "description": "Property id.", + "description": "Property id", "default": "", "type": "string" }, - "organizationId": { + "key": { "readOnly": true, - "description": "Property organizationId.", + "description": "Property key", "default": "", "type": "string" }, - "userId": { + "label": { "readOnly": true, - "description": "Property userId.", + "description": "Property label", "default": "", "type": "string" }, - "isActive": { - "readOnly": true, - "description": "Property isActive.", - "default": true, - "type": "boolean" - }, - "joinedAt": { + "fieldType": { "readOnly": true, - "description": "Property joinedAt.", + "description": "Property fieldType", "default": "", "type": "string" }, - "roles": { + "options": { "readOnly": true, "type": "array", "items": { - "$ref": "#/components/schemas/OrganizationRoleOutput.jsonld-Organization.read" + "type": "string" } }, - "permissions": { + "facilityType": { "readOnly": true, - "type": "array", - "items": { - "$ref": "#/components/schemas/OrganizationPermissionOutput.jsonld-Organization.read" - } + "description": "Property facilityType", + "type": [ + "string", + "null" + ] + }, + "required": { + "readOnly": true, + "description": "Property required", + "default": false, + "type": "boolean" + }, + "unit": { + "readOnly": true, + "description": "Property unit", + "type": [ + "string", + "null" + ] } } } ], - "description": "Resource CurrentOrganizationMemberProfileResource." + "description": "Organization-defined typed schema for facility metadata." }, - "CurrentUserProfile.CurrentUserProfileInput-user.write.jsonMergePatch": { + "FacilityMetadataField.UpdateFacilityMetadataFieldInput-facility.write.jsonMergePatch": { "type": "object", - "description": "Resource CurrentUserProfileResource.", + "description": "Organization-defined typed schema for facility metadata.", "properties": { - "firstName": { - "maxLength": 100, - "writeOnly": true, - "description": "User first name.", - "example": "Jane", - "type": "string" + "label": { + "minLength": 2, + "maxLength": 80, + "description": "Human-readable label (partial update)", + "type": [ + "string", + "null" + ] }, - "lastName": { - "maxLength": 100, - "writeOnly": true, - "description": "User last name.", - "example": "Doe", - "type": "string" + "fieldType": { + "enum": [ + "text", + "number", + "date", + "boolean", + "select" + ], + "description": "Field type (partial update)", + "type": [ + "string", + "null" + ] }, - "locale": { + "required": { + "description": "Whether the field is required on facility creation (partial update)", + "type": [ + "boolean", + "null" + ] + }, + "options": { + "description": "Select options (partial update)", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "facilityType": { "enum": [ - "system", - "en", - "fr", - "es" + "site", + "building", + "floor", + "zone", + "area" ], - "writeOnly": true, - "description": "Preferred display language. Omit to leave unchanged; \"system\" follows the browser language.", - "example": "fr", - "type": "string" + "description": "Optional facility type scope (partial update); null applies to every type", + "type": [ + "string", + "null" + ] + }, + "unit": { + "maxLength": 16, + "description": "Optional unit label (partial update)", + "type": [ + "string", + "null" + ] } } }, - "CurrentUserProfile.CurrentUserProfileOutput.jsonld-user.read": { + "FacilityModel.FacilityModelOutput.jsonld-facility.read": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -49843,143 +63449,134 @@ "type": "object", "properties": { "id": { - "readOnly": true, - "description": "Property id.", - "type": [ - "string", - "null" - ] - }, - "username": { - "readOnly": true, - "description": "Property username.", - "type": [ - "string", - "null" - ] - }, - "email": { - "readOnly": true, - "description": "Property email.", - "type": [ - "string", - "null" - ] - }, - "firstName": { - "readOnly": true, - "description": "Property firstName.", - "type": [ - "string", - "null" - ] + "description": "Method __construct.", + "type": "string" }, - "lastName": { - "readOnly": true, - "description": "Property lastName.", - "type": [ - "string", - "null" - ] + "organizationId": { + "description": "Method __construct.", + "type": "string" }, - "avatarUrl": { - "readOnly": true, - "description": "Property avatarUrl.", - "type": [ - "string", - "null" - ] + "buildingId": { + "description": "Method __construct.", + "type": "string" }, - "status": { - "readOnly": true, - "description": "Property status.", - "type": [ - "string", - "null" - ] + "fileName": { + "description": "Method __construct.", + "type": "string" }, - "emailVerified": { - "readOnly": true, - "description": "Property emailVerified.", - "default": false, - "type": "boolean" + "mimeType": { + "description": "Method __construct.", + "type": "string" }, - "tenantId": { - "readOnly": true, - "description": "Property tenantId.", - "type": [ - "string", - "null" - ] + "fileSize": { + "description": "Method __construct.", + "type": "integer" }, - "createdAt": { - "readOnly": true, - "description": "Property createdAt.", - "type": [ - "string", - "null" - ] + "nodeCount": { + "description": "Method __construct.", + "type": "integer" }, - "lastLoginAt": { - "readOnly": true, - "description": "Property lastLoginAt.", - "type": [ - "string", - "null" - ] + "nodes": { + "description": "Method __construct.", + "type": "array", + "items": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + } + ] + } + } }, - "locale": { - "readOnly": true, - "description": "Preferred display language. \"system\" follows the browser language.", - "default": "system", - "example": "fr", - "type": "string", - "enum": [ - "system", - "en", - "fr", - "es" - ] + "revision": { + "description": "Method __construct.", + "type": "integer" }, - "totpEnabled": { - "readOnly": true, - "description": "Whether the authenticated user has an active TOTP (authenticator app) enrollment.", - "default": false, - "example": false, + "active": { + "description": "Method __construct.", "type": "boolean" }, - "roles": { - "readOnly": true, - "description": "Resolved global role names for the authenticated user.", + "transform": { + "description": "Method __construct.", + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "object", + "additionalProperties": { + "type": "number" + } + }, + { + "type": "number" + } + ] + } + }, + "bindings": { + "description": "Method __construct.", "type": "array", "items": { - "type": "string" + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + } + ] + } } }, - "permissions": { - "readOnly": true, - "description": "Resolved global permission names for the authenticated user.", + "downloadUrl": { + "description": "Method __construct.", + "type": "string" + }, + "createdAt": { + "description": "Method __construct.", + "type": "string" + }, + "updatedAt": { + "description": "Method __construct.", + "type": "string" + }, + "bindingIssues": { + "description": "Method __construct.", "type": "array", "items": { - "type": "string" + "type": "object", + "required": [ + "nodeIndex", + "code" + ], + "additionalProperties": false, + "properties": { + "nodeIndex": { + "type": "integer", + "minimum": 0 + }, + "code": { + "type": "string", + "enum": [ + "target_unavailable" + ] + } + } } - }, - "avatarUrls": { - "readOnly": true, - "description": "Avatar URLs by size in pixels. Null when no avatar is set or the avatar is hosted externally.", - "type": "object", - "additionalProperties": { - "type": "string", - "format": "uri" - }, - "nullable": true } } } ], - "description": "Resource CurrentUserProfileResource." + "description": "Immutable self-contained GLB 2.0 building models; maximum 10 MiB and two models per building." }, - "DirectConversation.ConversationOutput.jsonld": { + "FacilityPlanCalibration.FacilityAttachmentOutput.jsonld-facility.read": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -49988,403 +63585,281 @@ "type": "object", "properties": { "id": { + "readOnly": true, "description": "Property id.", "default": "", "type": "string" }, - "organization": { - "description": "Property organization.", + "facilityId": { + "readOnly": true, + "description": "Property facilityId.", "default": "", "type": "string" }, - "subjectType": { - "description": "Property subjectType.", + "fileName": { + "readOnly": true, + "description": "Property fileName.", "default": "", "type": "string" }, - "subject": { - "description": "Property subject.", - "type": [ - "string", - "null" - ] - }, - "subjectLabel": { - "description": "Property subjectLabel.", - "type": [ - "string", - "null" - ] - }, - "visibility": { - "description": "Property visibility.", + "mimeType": { + "readOnly": true, + "description": "Property mimeType.", "default": "", "type": "string" }, - "lastMessageAt": { - "description": "Property lastMessageAt.", + "size": { + "readOnly": true, + "description": "Property size.", + "default": 0, + "type": "integer" + }, + "label": { + "readOnly": true, + "description": "Property label.", "type": [ "string", "null" ] }, - "messagesCount": { - "description": "Property messagesCount.", - "default": 0, - "type": "integer" - }, - "isArchived": { - "description": "Property isArchived.", - "default": false, - "type": "boolean" - }, - "unreadCount": { - "description": "Property unreadCount.", - "default": 0, + "revision": { + "readOnly": true, + "description": "Property revision.", + "default": 1, "type": "integer" }, - "createdAt": { - "description": "Property createdAt.", - "default": "", - "type": "string" - }, - "updatedAt": { - "description": "Property updatedAt.", - "default": "", + "kind": { + "readOnly": true, + "description": "Property kind.", + "default": "document", "type": "string" }, - "isChannel": { - "description": "Property isChannel.", + "isPrimaryPlan": { + "readOnly": true, + "description": "Property isPrimaryPlan.", "default": false, "type": "boolean" }, - "name": { - "description": "Property name.", + "imageWidth": { + "readOnly": true, + "description": "Property imageWidth.", "type": [ - "string", + "integer", "null" ] }, - "team": { - "description": "Property team.", + "imageHeight": { + "readOnly": true, + "description": "Property imageHeight.", "type": [ - "string", + "integer", "null" ] }, - "isFavorite": { - "description": "Property isFavorite.", - "default": false, - "type": "boolean" + "uploadedAt": { + "readOnly": true, + "description": "Property uploadedAt.", + "default": "", + "type": "string" }, - "parentConversationId": { - "description": "Property parentConversationId.", + "calibration": { + "readOnly": true, + "description": "Metric calibration of the original plan image.", + "type": [ + "object", + "null" + ], + "required": [ + "widthMeters", + "rotationDegrees", + "offsetXMeters", + "offsetZMeters" + ], + "properties": { + "widthMeters": { + "type": "number", + "exclusiveMinimum": 0, + "maximum": 100000 + }, + "rotationDegrees": { + "type": "number", + "minimum": -360, + "maximum": 360 + }, + "offsetXMeters": { + "type": "number", + "minimum": -100000, + "maximum": 100000 + }, + "offsetZMeters": { + "type": "number", + "minimum": -100000, + "maximum": 100000 + } + }, + "additionalProperties": false + }, + "calibrationBuildingId": { + "readOnly": true, + "description": "Property calibrationBuildingId", "type": [ "string", "null" - ] + ], + "format": "uuid" }, - "counterpartMember": { - "description": "Property counterpartMember.", + "calibrationIssue": { + "readOnly": true, + "description": "Property calibrationIssue", "type": [ "string", "null" + ], + "enum": [ + "building_changed", + "unverified_frame", + null ] } } } ], - "description": "Resource DirectConversationResource.\n\n1-to-1 private conversations between two organization members (L2.4) — a\n`Conversation` with `subjectType=DIRECT`, `visibility=PARTICIPANTS`, and\n`subjectId` set to the deterministic, order-independent pair key derived\nby `Messaging\\Domain\\Service\\DirectConversationKey`. Mirrors\n`ChannelResource`: a dedicated top-level creation endpoint PLUS its own\n\"list mine\" collection, while messages/read/subscription/favorite reuse\nthe EXISTING `/api/conversations/{id}/...` endpoints unchanged — a direct\nconversation id IS a conversation id. It deliberately does NOT appear in\n`GET /api/conversations` (see `MessagingConversationRepository::list()`) —\n`GET /api/direct-conversations` is the only list surface for a member's\ndirect conversations, scoped exactly like `GET /api/channels` (an INNER\nJOIN on `messaging_participants`), which is what preserves the privacy\ninvariant: a member can never discover a direct conversation they are not\na participant of, through either list." - }, - "DirectConversation.GetOrCreateDirectConversationInput": { - "type": "object", - "description": "Resource DirectConversationResource.\n\n1-to-1 private conversations between two organization members (L2.4) — a\n`Conversation` with `subjectType=DIRECT`, `visibility=PARTICIPANTS`, and\n`subjectId` set to the deterministic, order-independent pair key derived\nby `Messaging\\Domain\\Service\\DirectConversationKey`. Mirrors\n`ChannelResource`: a dedicated top-level creation endpoint PLUS its own\n\"list mine\" collection, while messages/read/subscription/favorite reuse\nthe EXISTING `/api/conversations/{id}/...` endpoints unchanged — a direct\nconversation id IS a conversation id. It deliberately does NOT appear in\n`GET /api/conversations` (see `MessagingConversationRepository::list()`) —\n`GET /api/direct-conversations` is the only list surface for a member's\ndirect conversations, scoped exactly like `GET /api/channels` (an INNER\nJOIN on `messaging_participants`), which is what preserves the privacy\ninvariant: a member can never discover a direct conversation they are not\na participant of, through either list.", - "required": [ - "organization", - "memberId" - ], - "properties": { - "organization": { - "description": "Property organization.", - "default": "", - "type": "string" - }, - "memberId": { - "format": "uuid", - "description": "Property memberId.", - "externalDocs": { - "url": "https://schema.org/identifier" - }, - "default": "", - "type": "string" - } - } + "description": "Resource FacilityAttachmentCalibrationResource." }, - "Discovery.JwksOutput-token.read": { + "FacilityPlanCalibration.SetFacilityAttachmentCalibrationInput-facility.write": { "type": "object", - "description": "OAuth2/OIDC discovery endpoints", + "description": "Resource FacilityAttachmentCalibrationResource.", "required": [ - "keys" + "calibration" ], "properties": { - "keys": { - "readOnly": true, - "description": "Array of JSON Web Keys for signature verification", - "example": [ - { - "kty": "RSA", - "use": "sig", - "alg": "RS256", - "kid": "key-id-123" - } + "calibration": { + "description": "Calibration in metres, or null to remove the metric calibration.", + "type": [ + "object", + "null" ], - "type": "array", - "items": { - "type": "object", - "properties": { - "kty": { - "type": "string", - "description": "Key type (e.g., RSA)" - }, - "use": { - "type": "string", - "description": "Key usage (sig for signature)" - }, - "alg": { - "type": "string", - "description": "Algorithm (e.g., RS256)" - }, - "kid": { - "type": "string", - "description": "Key identifier" - }, - "n": { - "type": "string", - "description": "RSA modulus (Base64URL)" - }, - "e": { - "type": "string", - "description": "RSA exponent (Base64URL)" - } + "required": [ + "widthMeters", + "rotationDegrees", + "offsetXMeters", + "offsetZMeters" + ], + "properties": { + "widthMeters": { + "type": "number", + "exclusiveMinimum": 0, + "maximum": 100000 + }, + "rotationDegrees": { + "type": "number", + "minimum": -360, + "maximum": 360 + }, + "offsetXMeters": { + "type": "number", + "minimum": -100000, + "maximum": 100000 + }, + "offsetZMeters": { + "type": "number", + "minimum": -100000, + "maximum": 100000 } - } + }, + "additionalProperties": true } } }, - "Discovery.OpenIdConfigurationOutput-token.read": { - "type": "object", - "description": "OAuth2/OIDC discovery endpoints", - "required": [ - "issuer", - "token_endpoint", - "userinfo_endpoint", - "jwks_uri" - ], - "properties": { - "issuer": { - "readOnly": true, - "description": "Issuer identifier URL of the authorization server", - "example": "https://auth.example.com", - "type": "string", - "format": "uri" - }, - "authorization_endpoint": { - "readOnly": true, - "description": "URL of the authorization endpoint", - "example": "https://auth.example.com/oauth2/authorize", - "type": "string", - "format": "uri", - "nullable": true - }, - "token_endpoint": { - "readOnly": true, - "description": "URL of the token endpoint", - "example": "https://auth.example.com/api/oauth2/token", - "type": "string", - "format": "uri" - }, - "userinfo_endpoint": { - "readOnly": true, - "description": "URL of the UserInfo endpoint", - "example": "https://auth.example.com/api/oauth2/userinfo", - "type": "string", - "format": "uri" - }, - "jwks_uri": { - "readOnly": true, - "description": "URL of the JWKS endpoint", - "example": "https://auth.example.com/api/.well-known/jwks.json", - "type": "string", - "format": "uri" - }, - "revocation_endpoint": { - "readOnly": true, - "description": "URL of the token revocation endpoint", - "example": "https://auth.example.com/api/oauth2/token/revoke", - "type": "string", - "format": "uri", - "nullable": true - }, - "introspection_endpoint": { - "readOnly": true, - "description": "URL of the token introspection endpoint", - "example": "https://auth.example.com/api/oauth2/token/introspect", - "type": "string", - "format": "uri", - "nullable": true - }, - "end_session_endpoint": { - "readOnly": true, - "description": "URL of the end session (logout) endpoint", - "example": "https://auth.example.com/api/oauth2/logout", - "type": "string", - "format": "uri", - "nullable": true - }, - "scopes_supported": { - "readOnly": true, - "description": "List of supported OAuth2 scopes", - "example": [ - "openid", - "profile", - "email", - "offline_access" - ], - "type": "array", - "items": { - "type": "string" - }, - "nullable": true - }, - "response_types_supported": { - "readOnly": true, - "description": "List of supported response types", - "example": [ - "code" - ], - "type": "array", - "items": { - "type": "string" - }, - "nullable": true - }, - "grant_types_supported": { - "readOnly": true, - "description": "List of supported grant types", - "example": [ - "client_credentials", - "refresh_token", - "authorization_code" - ], - "type": "array", - "items": { - "type": "string" - }, - "nullable": true - }, - "token_endpoint_auth_methods_supported": { - "readOnly": true, - "description": "Supported token endpoint authentication methods", - "example": [ - "client_secret_post" - ], - "type": "array", - "items": { - "type": "string" - }, - "nullable": true - }, - "code_challenge_methods_supported": { - "readOnly": true, - "description": "Supported PKCE code challenge methods", - "example": [ - "S256", - "plain" - ], - "type": "array", - "items": { - "type": "string" - }, - "nullable": true - }, - "prompt_values_supported": { - "readOnly": true, - "description": "Supported prompt values", - "example": [ - "none", - "login", - "consent", - "select_account" - ], - "type": "array", - "items": { - "type": "string" - }, - "nullable": true - }, - "subject_types_supported": { - "readOnly": true, - "description": "Supported subject identifier types", - "example": [ - "public" - ], - "type": "array", - "items": { - "type": "string" - }, - "nullable": true - }, - "id_token_signing_alg_values_supported": { - "readOnly": true, - "description": "Supported ID token signing algorithms", - "example": [ - "RS256" - ], - "type": "array", - "items": { - "type": "string" - }, - "nullable": true + "FacilityTree.FacilityTreeOutput.jsonld": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" }, - "claims_supported": { - "readOnly": true, - "description": "Supported OpenID Connect claims", - "example": [ - "sub", - "name", - "given_name", - "family_name", - "preferred_username", - "picture", - "email", - "email_verified", - "auth_time" - ], - "type": "array", - "items": { - "type": "string" - }, - "nullable": true + { + "type": "object", + "properties": { + "organizationId": { + "description": "Property organizationId.", + "default": "", + "type": "string" + }, + "generatedAt": { + "description": "Property generatedAt.", + "default": "", + "type": "string" + }, + "nodes": { + "description": "Property nodes.", + "type": "array", + "items": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "array", + "items": { + "type": [ + "string", + "null" + ] + } + }, + { + "type": "number" + }, + { + "type": "integer" + }, + { + "type": "string" + }, + { + "type": "null" + } + ] + } + } + } + } } - } + ], + "description": "Enriched facility hierarchy (Site/Building/Floor/Zone) with per-node equipment count and compliance verdict/rate, batched by facility-id list. Requires organization.compliance.read." }, - "EmailChange.ConfirmEmailChangeInput-email_change.write": { + "FederatedAuth.FederatedCompleteInput": { "type": "object", - "description": "Sign-in email address change operations (email confirmation protected)", - "required": [ - "token" - ], + "description": "Resource FederatedAuthResource.\n\nDeclares the Google and Microsoft sign-in, callback and connection API.", "properties": { - "token": { + "code": { + "maxLength": 4096, + "description": "Property code", + "type": [ + "string", + "null" + ] + }, + "error": { "maxLength": 128, - "writeOnly": true, - "description": "Confirmation token from the email sent to the new address", - "example": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", - "type": "string" + "description": "Property error", + "type": [ + "string", + "null" + ] + }, + "state": { + "maxLength": 256, + "description": "Property state", + "type": [ + "string", + "null" + ] } - } + }, + "required": [ + "state" + ] }, - "EmailChange.ConfirmEmailChangeOutput.jsonld-email_change.read": { + "FederatedAuth.FederatedConnectionsOutput.jsonld": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -50392,50 +63867,62 @@ { "type": "object", "properties": { - "success": { - "description": "Whether the email address was changed successfully", - "example": true, + "password_configured": { + "description": "Property passwordConfigured.", "type": "boolean" }, - "message": { - "description": "Informational message", - "example": "Your email address has been changed. Please sign in again with the new address.", + "last_sign_in_method": { + "description": "Property lastSignInMethod.", + "type": [ + "string", + "null" + ] + }, + "connections": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FederatedConnectionOutput.jsonld" + } + } + } + } + ], + "description": "Resource FederatedAuthResource.\n\nDeclares the Google and Microsoft sign-in, callback and connection API." + }, + "FederatedAuth.FederatedProviderOutput.jsonld": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { + "provider": { + "description": "Method __construct", "type": "string" + }, + "enabled": { + "description": "Method __construct", + "type": "boolean" } } } ], - "description": "Sign-in email address change operations (email confirmation protected)" + "description": "Resource FederatedAuthResource.\n\nDeclares the Google and Microsoft sign-in, callback and connection API." }, - "EmailChange.RequestEmailChangeInput-email_change.write": { + "FederatedAuth.FederatedStartInput": { "type": "object", - "description": "Sign-in email address change operations (email confirmation protected)", - "required": [ - "newEmail", - "currentPassword" - ], + "description": "Resource FederatedAuthResource.\n\nDeclares the Google and Microsoft sign-in, callback and connection API.", "properties": { - "newEmail": { - "format": "email", - "maxLength": 320, - "writeOnly": true, - "description": "The new sign-in email address; a confirmation link is sent to it", - "externalDocs": { - "url": "https://schema.org/email" - }, - "example": "new-address@example.com", + "return_url": { + "maxLength": 500, + "description": "Property returnUrl", + "default": "/", "type": "string" - }, - "currentPassword": { - "writeOnly": true, - "description": "Current password, verified before the confirmation email is sent", - "example": "CurrentP@ssw0rd!", - "type": "string", - "format": "password" } } }, - "EmailChange.RequestEmailChangeOutput.jsonld-email_change.read": { + "FederatedAuth.FederatedStartOutput.jsonld": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -50443,212 +63930,347 @@ { "type": "object", "properties": { - "success": { - "description": "Whether the email change request was accepted", + "authorization_url": { + "description": "Method __construct", + "type": "string" + } + } + } + ], + "description": "Resource FederatedAuthResource.\n\nDeclares the Google and Microsoft sign-in, callback and connection API." + }, + "FederatedAuth.LoginOutput.jsonld-token.read": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { + "access_token": { + "readOnly": true, + "description": "JWT access token for API authentication; absent while MFA is required", + "example": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...", + "type": "string" + }, + "token_type": { + "readOnly": true, + "description": "Token type; absent while MFA is required", + "example": "Bearer", + "type": "string", + "enum": [ + "Bearer" + ], + "default": "Bearer" + }, + "expires_in": { + "readOnly": true, + "description": "Token lifetime in seconds; absent while MFA is required", + "example": 3600, + "type": "integer", + "minimum": 1 + }, + "scope": { + "readOnly": true, + "description": "Space-separated list of granted scopes", + "example": "openid profile email", + "type": "string", + "nullable": true + }, + "mfa_required": { + "readOnly": true, + "description": "If true, authentication is incomplete. Use mfa_token to verify code.", "example": true, - "type": "boolean" + "type": "boolean", + "nullable": true }, - "message": { - "description": "Informational message", - "example": "A confirmation link has been sent to the new email address.", - "type": "string" + "mfa_token": { + "readOnly": true, + "description": "Temporary Pre-Auth Token (JWT) covering the partial authentication state.", + "example": "eyJ...", + "type": "string", + "nullable": true }, - "expiresAt": { - "description": "When the confirmation token expires", - "example": "2026-08-28T11:00:00+00:00", + "challenge_token": { + "readOnly": true, + "description": "The OTP challenge token (reference).", + "example": "abc...", + "type": "string", + "nullable": true + }, + "mfa_method": { + "readOnly": true, + "description": "The delivery method for the MFA code (email, sms, totp).", + "example": "email", + "type": "string", + "enum": [ + "email", + "sms", + "totp" + ], + "nullable": true + }, + "mfa_destination": { + "readOnly": true, + "description": "The masked destination where the code was sent (e.g., \"j***e@e****e.com\" or \"+336****5678\").", + "example": "j***e@e****e.com", + "type": "string", + "nullable": true + }, + "mfa_resend_in": { + "readOnly": true, + "description": "Seconds until MFA code can be resent", + "example": 60, + "type": "integer", + "nullable": true + }, + "return_url": { + "description": "Validated local destination returned by a federated login.", "type": [ "string", "null" - ], - "format": "date-time" + ] + }, + "new_account": { + "description": "Whether a federated login provisioned the Fireguard account.", + "type": [ + "boolean", + "null" + ] } } } ], - "description": "Sign-in email address change operations (email confirmation protected)" + "description": "Resource FederatedAuthResource.\n\nDeclares the Google and Microsoft sign-in, callback and connection API." }, - "EmailOwnership.ConfirmEmailOwnershipInput-email_ownership.write": { + "FederatedConnectionOutput.jsonld": { "type": "object", - "description": "Resource EmailOwnershipResource.", - "required": [ - "challengeToken", - "code" - ], "properties": { - "challengeToken": { - "default": "", + "provider": { + "description": "Property provider", "type": "string" }, - "code": { - "default": "", + "email": { + "description": "Property email", + "type": "string" + }, + "connected_at": { + "description": "Property connectedAt", + "type": "string" + }, + "last_used_at": { + "description": "Property lastUsedAt", "type": "string" } } }, - "EmailOwnership.EmailOwnershipOutput.jsonld-email_ownership.read": { + "Health.HealthOutput.jsonld-health.read_health.read.details": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" }, { "type": "object", + "required": [ + "status", + "timestamp", + "version" + ], "properties": { - "verified": { - "description": "current mailbox ownership proof", - "type": "boolean" + "status": { + "readOnly": true, + "description": "Overall health status of the service", + "default": "", + "example": "healthy", + "type": "string", + "enum": [ + "healthy", + "degraded", + "unhealthy" + ] + }, + "timestamp": { + "readOnly": true, + "description": "Current server timestamp (ISO 8601)", + "default": "", + "example": "2026-01-30T10:15:30+00:00", + "type": "string", + "format": "date-time" + }, + "database": { + "readOnly": true, + "description": "Database connectivity status (when details are enabled)", + "example": true, + "type": "boolean", + "nullable": true + }, + "cache": { + "readOnly": true, + "description": "Cache connectivity status (when details are enabled)", + "example": true, + "type": "boolean", + "nullable": true + }, + "version": { + "readOnly": true, + "description": "Running application version", + "default": "1.0.0", + "example": "1.0.0", + "type": "string" } } } ], - "description": "Resource EmailOwnershipResource." + "description": "Application health check" }, - "EmailOwnership.StartEmailOwnershipOutput.jsonld-email_ownership.read": { + "HydraCollectionBaseSchema": { "allOf": [ { - "$ref": "#/components/schemas/HydraItemBaseSchema" + "$ref": "#/components/schemas/HydraCollectionBaseSchemaNoPagination" }, { "type": "object", "properties": { - "challengeToken": { - "description": "single-use email challenge token", + "view": { + "type": "object", + "properties": { + "@id": { + "type": "string", + "format": "iri-reference" + }, + "@type": { + "type": "string" + }, + "first": { + "type": [ + "string", + "null" + ], + "format": "iri-reference" + }, + "last": { + "type": [ + "string", + "null" + ], + "format": "iri-reference" + }, + "previous": { + "type": [ + "string", + "null" + ], + "format": "iri-reference" + }, + "next": { + "type": [ + "string", + "null" + ], + "format": "iri-reference" + } + }, + "example": { + "@id": "string", + "@type": "string", + "first": "string", + "last": "string", + "previous": "string", + "next": "string" + } + } + } + } + ] + }, + "HydraCollectionBaseSchemaNoPagination": { + "type": "object", + "properties": { + "totalItems": { + "type": "integer", + "minimum": 0 + }, + "search": { + "type": "object", + "properties": { + "@type": { "type": "string" }, - "canResendIn": { - "description": "seconds until resend", - "type": "integer" + "template": { + "type": "string" + }, + "variableRepresentation": { + "type": "string" + }, + "mapping": { + "type": "array", + "items": { + "type": "object", + "properties": { + "@type": { + "type": "string" + }, + "variable": { + "type": "string" + }, + "property": { + "type": [ + "string", + "null" + ] + }, + "required": { + "type": "boolean" + } + } + } } } } - ], - "description": "Resource EmailOwnershipResource." + } }, - "Equipment.CreateEquipmentInput": { + "HydraItemBaseSchema": { "type": "object", - "description": "Resource CanonicalEquipmentResource.", "properties": { - "clientId": { - "format": "uuid", - "description": "Client-generated UUID for idempotent offline creation", - "externalDocs": { - "url": "https://schema.org/identifier" - }, - "type": [ - "string", - "null" - ] - }, - "organization": { - "pattern": "^(/api/organizations/[0-9a-fA-F-]{36})$", - "description": "Organization IRI, required on the canonical endpoint", - "type": [ - "string", - "null" - ] - }, - "intervention": { - "pattern": "^(/api/interventions/[0-9a-fA-F-]{36})$", - "description": "Optional field intervention IRI", - "type": [ - "string", - "null" - ] - }, - "facility": { - "pattern": "^(/api/facilities/[0-9a-fA-F-]{36})$", - "description": "Optional facility IRI", - "type": [ - "string", - "null" - ] - }, - "onboardingSessionId": { - "format": "uuid", - "externalDocs": { - "url": "https://schema.org/identifier" - }, - "type": [ - "string", - "null" - ] - }, - "onboardingItemKey": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "fire_extinguisher", - "smoke_detector", - "heat_detector", - "sprinkler", - "fire_alarm_panel", - "hydrant", - "fire_door", - "emergency_lighting", - "access_control", - "camera", - "gas_detector", - "other" - ], - "description": "Equipment type", - "default": "", - "example": "fire_extinguisher", - "type": "string" - }, - "subType": { - "maxLength": 100, - "description": "Optional free-form sub-type", - "example": "CO2 6kg", - "type": [ - "string", - "null" - ] - }, - "brand": { - "maxLength": 100, - "description": "Optional brand name", - "example": "Sicli", - "type": [ - "string", - "null" - ] - }, - "model": { - "maxLength": 100, - "description": "Optional model name", - "example": "Pro 6", - "type": [ - "string", - "null" + "@context": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "@vocab": { + "type": "string" + }, + "hydra": { + "type": "string", + "enum": [ + "http://www.w3.org/ns/hydra/core#" + ] + } + }, + "required": [ + "@vocab", + "hydra" + ], + "additionalProperties": true + } ] }, - "serialNumber": { - "maxLength": 100, - "description": "Optional serial number (unique per organization)", - "example": "SN-2024-001", - "type": [ - "string", - "null" - ] + "@id": { + "type": "string" }, - "locationLabel": { - "maxLength": 255, - "description": "Optional free-form location label", - "example": "Near staircase B", - "type": [ - "string", - "null" - ] + "@type": { + "type": "string" } }, "required": [ - "type" + "@id", + "@type" ] }, - "Equipment.EquipmentOutput.jsonld": { + "ImportJob.ImportJobOutput.jsonld": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -50656,282 +64278,166 @@ { "type": "object", "properties": { - "intervention": { - "description": "Property intervention.", - "type": [ - "string", - "null" - ] - }, - "recordStatus": { - "description": "Property recordStatus.", - "default": "published", - "type": "string" - }, - "revision": { - "description": "Property revision.", - "default": 1, - "type": "integer" + "canResume": { + "description": "True only when the current member may resume and no live worker owns the job.", + "default": false, + "type": "boolean" }, "id": { - "readOnly": true, "description": "Property id.", "default": "", "type": "string" }, - "organizationId": { - "readOnly": true, - "description": "Property organizationId.", + "organization": { + "description": "Property organization.", "default": "", "type": "string" }, - "facilityId": { - "readOnly": true, - "description": "Property facilityId.", - "type": [ - "string", - "null" - ] + "kind": { + "description": "Property kind.", + "default": "", + "type": "string" }, - "type": { - "readOnly": true, - "description": "Property type.", + "status": { + "description": "Property status.", "default": "", "type": "string" }, - "subType": { - "readOnly": true, - "description": "Property subType.", - "type": [ - "string", - "null" - ] + "originalFilename": { + "description": "Property originalFilename.", + "default": "", + "type": "string" }, - "brand": { - "readOnly": true, - "description": "Property brand.", - "type": [ - "string", - "null" - ] + "dryRun": { + "description": "Property dryRun.", + "default": false, + "type": "boolean" }, - "model": { - "readOnly": true, - "description": "Property model.", + "totalRows": { + "description": "Property totalRows.", "type": [ - "string", + "integer", "null" ] }, - "serialNumber": { - "readOnly": true, - "description": "Property serialNumber.", - "type": [ - "string", - "null" - ] + "processedRows": { + "description": "Property processedRows.", + "default": 0, + "type": "integer" }, - "locationLabel": { - "readOnly": true, - "description": "Property locationLabel.", - "type": [ - "string", - "null" - ] + "successfulRows": { + "description": "Property successfulRows.", + "default": 0, + "type": "integer" }, - "facilityName": { - "readOnly": true, - "description": "Display name of the assigned facility", + "failedRows": { + "description": "Property failedRows.", + "default": 0, + "type": "integer" + }, + "errorReport": { + "description": "Property errorReport.", + "type": "array", + "items": { + "$ref": "#/components/schemas/ImportRowErrorOutput.jsonld" + } + }, + "reportPage": { + "description": "Current report page, starting at one.", + "default": 1, + "type": "integer" + }, + "reportItemsPerPage": { + "description": "Maximum report rows returned per page.", + "default": 100, + "type": "integer" + }, + "reportTotal": { + "description": "Total confirmed report rows for this job.", + "default": 0, + "type": "integer" + }, + "reportHasNextPage": { + "description": "Whether a subsequent report page exists.", + "default": false, + "type": "boolean" + }, + "jobError": { + "description": "Property jobError.", "type": [ "string", "null" ] }, - "status": { - "readOnly": true, - "description": "Property status.", + "createdAt": { + "description": "Property createdAt.", "default": "", "type": "string" }, - "installedAt": { - "readOnly": true, - "description": "Property installedAt.", + "startedAt": { + "description": "Property startedAt.", "type": [ "string", "null" ] }, - "commissionedAt": { - "readOnly": true, - "description": "Property commissionedAt.", + "completedAt": { + "description": "Property completedAt.", "type": [ "string", "null" ] }, - "tags": { - "readOnly": true, - "description": "Property tags.", - "type": "array", - "items": { - "$ref": "#/components/schemas/TagOutput.jsonld" - } - }, - "maintenanceDueStatus": { - "readOnly": true, - "description": "Property maintenanceDueStatus.", - "default": "unscheduled", - "type": "string" - }, - "planPosition": { - "readOnly": true, - "description": "Position pinned on a floor plan attachment (detail read only)", - "type": [ - "object", - "null" - ], - "additionalProperties": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string" - } - ] - } - }, - "createdAt": { - "readOnly": true, - "description": "Property createdAt.", - "default": "", - "type": "string" - }, "updatedAt": { - "readOnly": true, "description": "Property updatedAt.", "default": "", "type": "string" + }, + "canConfirm": { + "description": "Property canConfirm", + "default": false, + "type": "boolean" + }, + "confirmedJobId": { + "description": "Property confirmedJobId", + "type": [ + "string", + "null" + ] } } } ], - "description": "Resource CanonicalEquipmentResource." + "description": "Bulk CSV import batches provisioning Equipment or Facility resources." }, - "Equipment.PatchCanonicalEquipmentInput.jsonMergePatch": { + "ImportRowErrorOutput.jsonld": { "type": "object", - "description": "Resource CanonicalEquipmentResource.", "properties": { - "type": { - "maxLength": 32, - "description": "Property type.", - "type": [ - "string", - "null" - ] - }, - "subType": { - "maxLength": 100, - "description": "Property subType.", - "type": [ - "string", - "null" - ] - }, - "brand": { - "maxLength": 100, - "description": "Property brand.", - "type": [ - "string", - "null" - ] - }, - "model": { - "maxLength": 100, - "description": "Property model.", - "type": [ - "string", - "null" - ] - }, - "serialNumber": { - "maxLength": 100, - "description": "Property serialNumber.", - "type": [ - "string", - "null" - ] - }, - "locationLabel": { - "maxLength": 255, - "description": "Property locationLabel.", - "type": [ - "string", - "null" - ] - }, - "status": { - "enum": [ - "in_stock", - "operational", - "decommissioned", - "under_maintenance" - ], - "description": "Property status.", - "type": [ - "string", - "null" - ] + "rowNumber": { + "description": "Property rowNumber.", + "default": 0, + "type": "integer" }, - "facility": { - "description": "Property facility.", + "column": { + "description": "Property column.", "type": [ "string", "null" ] - } - } - }, - "EquipmentAttachment.AddAttachmentInput-equipment.write": { - "type": "object", - "description": "File attachments linked to equipment items.", - "required": [ - "fileName", - "content", - "mimeType" - ], - "properties": { - "fileName": { - "maxLength": 255, - "description": "Original file name", - "default": "", - "example": "maintenance_report.pdf", - "type": "string" }, - "content": { - "description": "Base64-encoded file contents", + "code": { + "description": "Property code.", "default": "", "type": "string" }, - "mimeType": { - "maxLength": 100, - "description": "MIME type", + "message": { + "description": "Property message.", "default": "", - "example": "application/pdf", "type": "string" - }, - "label": { - "maxLength": 255, - "description": "Optional human-readable label", - "example": "Annual maintenance report 2024", - "type": [ - "string", - "null" - ] } } }, - "EquipmentAttachment.AttachmentOutput.jsonld-equipment.read": { + "ImportTemplate.ImportTemplateOutput.jsonld": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -50940,98 +64446,27 @@ "type": "object", "properties": { "id": { - "readOnly": true, - "description": "Property id.", - "default": "", - "type": "string" - }, - "equipmentId": { - "readOnly": true, - "description": "Property equipmentId.", - "default": "", + "description": "Method __construct", "type": "string" }, - "fileName": { - "readOnly": true, - "description": "Property fileName.", - "default": "", + "filename": { + "description": "Method __construct", "type": "string" }, - "mimeType": { - "readOnly": true, - "description": "Property mimeType.", - "default": "", + "content": { + "description": "Method __construct", "type": "string" }, - "size": { - "readOnly": true, - "description": "Property size.", - "default": 0, - "type": "integer" - }, - "label": { - "readOnly": true, - "description": "Property label.", - "type": [ - "string", - "null" - ] - }, - "revision": { - "readOnly": true, - "description": "Property revision.", - "default": 1, - "type": "integer" - }, - "uploadedAt": { - "readOnly": true, - "description": "Property uploadedAt.", - "default": "", + "mediaType": { + "description": "Method __construct", "type": "string" } } } ], - "description": "File attachments linked to equipment items." - }, - "EquipmentKpi.EquipmentKpiOutput.jsonld-equipment.read": { - "allOf": [ - { - "$ref": "#/components/schemas/HydraItemBaseSchema" - }, - { - "type": "object", - "properties": { - "totalAssets": { - "readOnly": true, - "description": "Property totalAssets.", - "default": 0, - "type": "integer" - }, - "compliant": { - "readOnly": true, - "description": "Property compliant.", - "default": 0, - "type": "integer" - }, - "dueSoon": { - "readOnly": true, - "description": "Property dueSoon.", - "default": 0, - "type": "integer" - }, - "openNonConformities": { - "readOnly": true, - "description": "Property openNonConformities.", - "default": 0, - "type": "integer" - } - } - } - ], - "description": "Aggregate equipment KPI counters for the organization equipment overview page." + "description": "Resource ImportTemplateResource. Organization-scoped server-owned CSV templates." }, - "EquipmentMaintenanceLog.MaintenanceLogOutput.jsonld-equipment.read": { + "Inbox.InboxOutput.jsonld-notification.inbox.read": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -51039,107 +64474,48 @@ { "type": "object", "properties": { - "id": { - "readOnly": true, - "description": "Property id.", - "default": "", - "type": "string" - }, - "equipmentId": { - "readOnly": true, - "description": "Property equipmentId.", - "default": "", - "type": "string" - }, - "organizationId": { - "readOnly": true, - "description": "Property organizationId.", - "default": "", - "type": "string" - }, - "startedAt": { - "readOnly": true, - "description": "Property startedAt.", - "default": "", - "type": "string" - }, - "completedAt": { - "readOnly": true, - "description": "Property completedAt.", - "type": [ - "string", - "null" - ] - }, - "source": { - "readOnly": true, - "description": "Property source.", - "default": "status_transition", - "type": "string" - }, - "interventionId": { - "readOnly": true, - "description": "Property interventionId.", - "type": [ - "string", - "null" - ] - }, - "interventionNumber": { - "readOnly": true, - "description": "Property interventionNumber.", - "type": [ - "integer", - "null" - ] - }, - "workItemAction": { + "items": { "readOnly": true, - "description": "Property workItemAction.", - "type": [ - "string", - "null" - ] + "description": "Property items.", + "type": "array", + "items": { + "$ref": "#/components/schemas/InboxItemOutput.jsonld-notification.inbox.read" + } }, - "actorId": { + "nextCursor": { "readOnly": true, - "description": "Property actorId.", + "description": "Property nextCursor.", "type": [ "string", "null" ] }, - "summary": { + "nextPageCursor": { "readOnly": true, - "description": "Property summary.", + "description": "Opaque composite cursor echoed unchanged as `cursor`; legacy nextCursor remains a date.", "type": [ "string", "null" ] + }, + "complete": { + "readOnly": true, + "description": "False when a source is unavailable; retry this page instead of advancing its cursor.", + "default": true, + "type": "boolean" + }, + "hasMore": { + "readOnly": true, + "description": "Property hasMore.", + "default": false, + "type": "boolean" } } } ], - "description": "Maintenance windows recorded for equipment items." - }, - "EquipmentTag.AddTagInput-equipment.write": { - "type": "object", - "description": "Tags associated with equipment items.", - "required": [ - "name" - ], - "properties": { - "name": { - "minLength": 1, - "maxLength": 100, - "description": "Tag name (case-insensitive, normalized to lowercase)", - "default": "", - "example": "vérification annuelle", - "type": "string" - } - } + "description": "Unified feed of things needing the authenticated user's attention, merged from every registered source." }, - "EquipmentTag.TagOutput.jsonld-equipment.read": { + "Inbox.InboxUnreadCountOutput.jsonld-notification.inbox.unread_count": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -51147,136 +64523,105 @@ { "type": "object", "properties": { - "id": { - "readOnly": true, - "description": "Property id.", - "default": "", - "type": "string" - }, - "name": { - "readOnly": true, - "description": "Property name.", - "default": "", - "type": "string" - }, - "organizationId": { + "unreadCount": { "readOnly": true, - "description": "Property organizationId.", - "default": "", - "type": "string" + "description": "Property unreadCount.", + "default": 0, + "type": "integer" } } } ], - "description": "Tags associated with equipment items." + "description": "Unified feed of things needing the authenticated user's attention, merged from every registered source." }, - "Error": { + "InboxItemOutput.jsonld-notification.inbox.read": { "type": "object", - "description": "A representation of common errors.", "properties": { - "title": { + "targetKind": { "readOnly": true, - "description": "A short, human-readable summary of the problem.", + "description": "Optional navigation discriminator supplied by the source, preserving targetType.", "type": [ "string", "null" ] }, - "detail": { + "sourceType": { "readOnly": true, - "description": "A human-readable explanation specific to this occurrence of the problem.", + "description": "Source-owned event type for presentation, when the source supplies one.", "type": [ "string", "null" ] }, - "status": { + "sourceKey": { + "readOnly": true, + "description": "Property sourceKey.", + "default": "", + "type": "string" + }, + "id": { + "readOnly": true, + "description": "Property id.", + "default": "", + "type": "string" + }, + "kind": { + "readOnly": true, + "description": "Property kind.", + "default": "", + "type": "string" + }, + "title": { + "readOnly": true, + "description": "Property title.", + "default": "", + "type": "string" + }, + "snippet": { + "readOnly": true, + "description": "Property snippet.", "type": [ - "integer", + "string", "null" - ], - "examples": [ - 404 - ], - "default": 400 + ] }, - "instance": { + "occurredAt": { "readOnly": true, - "description": "A URI reference that identifies the specific occurrence of the problem. It may or may not yield further information if dereferenced.", + "description": "Property occurredAt.", + "default": "", + "type": "string" + }, + "isRead": { + "readOnly": true, + "description": "Property isRead.", + "default": false, + "type": "boolean" + }, + "organizationId": { + "readOnly": true, + "description": "Property organizationId.", "type": [ "string", "null" ] }, - "type": { + "targetType": { "readOnly": true, - "description": "A URI reference that identifies the problem type", + "description": "Property targetType.", + "default": "", "type": "string" - } - } - }, - "Error.jsonld": { - "allOf": [ - { - "$ref": "#/components/schemas/HydraItemBaseSchema" }, - { - "type": "object", - "properties": { - "title": { - "readOnly": true, - "description": "A short, human-readable summary of the problem.", - "type": [ - "string", - "null" - ] - }, - "detail": { - "readOnly": true, - "description": "A human-readable explanation specific to this occurrence of the problem.", - "type": [ - "string", - "null" - ] - }, - "status": { - "type": [ - "integer", - "null" - ], - "examples": [ - 404 - ], - "default": 400 - }, - "instance": { - "readOnly": true, - "description": "A URI reference that identifies the specific occurrence of the problem. It may or may not yield further information if dereferenced.", - "type": [ - "string", - "null" - ] - }, - "type": { - "readOnly": true, - "description": "A URI reference that identifies the problem type", - "type": "string" - }, - "description": { - "readOnly": true, - "type": [ - "string", - "null" - ] - } - } + "targetId": { + "readOnly": true, + "description": "Property targetId.", + "default": "", + "type": "string" } - ], - "description": "A representation of common errors." + } }, - "Facility.CreateFacilityInput": { + "Inspection.CreateInspectionInput": { "type": "object", - "description": "Resource CanonicalFacilityResource.", + "description": "Resource CanonicalInspectionResource.", "properties": { "clientId": { "format": "uuid", @@ -51305,145 +64650,313 @@ "null" ] }, - "onboardingSessionId": { + "equipmentId": { "format": "uuid", + "description": "Equipment identifier", "externalDocs": { "url": "https://schema.org/identifier" }, - "type": [ - "string", - "null" - ] + "default": "", + "example": "550e8400-e29b-41d4-a716-446655440000", + "type": "string" }, - "onboardingItemKey": { - "type": [ - "string", - "null" - ] + "result": { + "enum": [ + "pass", + "fail", + "partial" + ], + "description": "Inspection result", + "default": "", + "example": "pass", + "type": "string" }, - "type": { + "performedAt": { + "description": "Date the inspection was performed (ISO 8601)", + "default": "", + "example": "2024-06-15T10:00:00+02:00", + "type": "string" + }, + "inspectorType": { "enum": [ - "site", - "building", - "floor", - "zone", - "area" + "user", + "external" ], - "description": "Facility type", + "description": "Inspector type (user or external)", "default": "", - "example": "site", + "example": "user", "type": "string" }, - "name": { - "minLength": 2, - "maxLength": 120, - "description": "Facility display name", + "inspectorName": { + "maxLength": 255, + "description": "Inspector display name", "default": "", - "example": "Headquarters", + "example": "Jean Dupont", "type": "string" }, - "parentFacilityId": { + "facilityId": { "format": "uuid", - "description": "Optional parent facility identifier", + "description": "Optional facility identifier", "externalDocs": { "url": "https://schema.org/identifier" }, - "example": "550e8400-e29b-41d4-a716-446655440001", "type": [ "string", "null" ] }, - "code": { - "maxLength": 80, - "description": "Optional facility code", - "example": "SITE-PAR-001", + "checklistId": { + "format": "uuid", + "description": "Optional checklist identifier", + "externalDocs": { + "url": "https://schema.org/identifier" + }, "type": [ "string", "null" ] }, - "address": { + "inspectorUserId": { + "format": "uuid", + "description": "Optional user identifier (required for user type)", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "type": [ + "string", + "null" + ] + }, + "inspectorOrganizationName": { "maxLength": 255, - "description": "Optional address", - "example": "10 rue de Rivoli, Paris", + "description": "Optional external organization name", "type": [ "string", "null" ] }, - "latitude": { - "minimum": -90, - "maximum": 90, - "description": "Optional latitude, required together with longitude", - "example": 48.8566, + "notes": { + "maxLength": 5000, + "description": "Optional free-form notes", "type": [ - "number", + "string", "null" ] }, - "longitude": { - "minimum": -180, - "maximum": 180, - "description": "Optional longitude, required together with latitude", - "example": 2.3522, + "signature": { + "description": "Optional signature data", "type": [ - "number", + "string", "null" ] + } + }, + "required": [ + "equipmentId", + "result", + "performedAt", + "inspectorType", + "inspectorName" + ] + }, + "Inspection.InspectionOutput.jsonld": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" }, - "metadata": { - "description": "Optional free-form metadata", - "example": { - "country": "FR", - "timezone": "Europe/Paris" - }, + { "type": "object", - "additionalProperties": { - "type": [ - "string", - "null" - ] + "properties": { + "intervention": { + "description": "Property intervention.", + "type": [ + "string", + "null" + ] + }, + "recordStatus": { + "description": "Property recordStatus.", + "default": "published", + "type": "string" + }, + "revision": { + "description": "Property revision.", + "default": 1, + "type": "integer" + }, + "id": { + "readOnly": true, + "description": "Property id.", + "default": "", + "type": "string" + }, + "organizationId": { + "readOnly": true, + "description": "Property organizationId.", + "default": "", + "type": "string" + }, + "equipmentId": { + "readOnly": true, + "description": "Property equipmentId.", + "default": "", + "type": "string" + }, + "facilityId": { + "readOnly": true, + "description": "Property facilityId.", + "type": [ + "string", + "null" + ] + }, + "equipmentSerialNumber": { + "readOnly": true, + "description": "Serial number of the inspected equipment", + "type": [ + "string", + "null" + ] + }, + "facilityName": { + "readOnly": true, + "description": "Display name of the inspected facility", + "type": [ + "string", + "null" + ] + }, + "checklistName": { + "readOnly": true, + "description": "Display name of the followed checklist", + "type": [ + "string", + "null" + ] + }, + "result": { + "readOnly": true, + "description": "Property result.", + "default": "", + "type": "string" + }, + "status": { + "readOnly": true, + "description": "Property status.", + "default": "", + "type": "string" + }, + "performedAt": { + "readOnly": true, + "description": "Property performedAt.", + "default": "", + "type": "string" + }, + "inspector": { + "readOnly": true, + "description": "Property inspector.", + "anyOf": [ + { + "$ref": "#/components/schemas/InspectorOutput.jsonld" + }, + { + "type": "null" + } + ] + }, + "checklistId": { + "readOnly": true, + "description": "Property checklistId.", + "type": [ + "string", + "null" + ] + }, + "notes": { + "readOnly": true, + "description": "Property notes.", + "type": [ + "string", + "null" + ] + }, + "signature": { + "readOnly": true, + "description": "Property signature.", + "type": [ + "string", + "null" + ] + }, + "nonConformitiesCount": { + "readOnly": true, + "description": "Property nonConformitiesCount.", + "default": 0, + "type": "integer" + }, + "createdAt": { + "readOnly": true, + "description": "Property createdAt.", + "default": "", + "type": "string" + }, + "updatedAt": { + "readOnly": true, + "description": "Property updatedAt.", + "default": "", + "type": "string" + } } + } + ], + "description": "Resource CanonicalInspectionResource." + }, + "Inspection.PatchCanonicalInspectionInput.jsonMergePatch": { + "type": "object", + "description": "Resource CanonicalInspectionResource.", + "properties": { + "result": { + "enum": [ + "pass", + "fail", + "partial" + ], + "description": "Property result.", + "type": [ + "string", + "null" + ] }, - "levelIndex": { - "minimum": -100, - "maximum": 200, - "description": "Optional stacking order of the floor (ground floor = 0, first basement = -1)", - "example": 0, + "status": { + "enum": [ + "draft", + "submitted", + "closed" + ], + "description": "Property status.", "type": [ - "integer", + "string", "null" ] }, - "elevationMeters": { - "minimum": -10000, - "maximum": 10000, - "description": "Optional floor elevation in meters", - "example": -3, + "notes": { + "maxLength": 5000, + "description": "Property notes.", "type": [ - "number", + "string", "null" ] }, - "heightMeters": { - "minimum": 0, - "exclusiveMinimum": 0, - "maximum": 1000, - "description": "Optional floor height in meters", - "example": 3, + "signature": { + "description": "Property signature.", "type": [ - "number", + "string", "null" ] } - }, - "required": [ - "type", - "name" - ] + } }, - "Facility.FacilityOutput.jsonld": { + "InspectionAttachment.InspectionAttachmentOutput.jsonld-inspection.read": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -51451,359 +64964,409 @@ { "type": "object", "properties": { - "intervention": { - "description": "Property intervention.", - "type": [ - "string", - "null" - ] - }, - "recordStatus": { - "description": "Property recordStatus.", - "default": "published", - "type": "string" - }, - "revision": { - "description": "Property revision.", - "default": 1, - "type": "integer" - }, "id": { "readOnly": true, "description": "Property id.", "default": "", "type": "string" }, - "organizationId": { + "inspectionId": { "readOnly": true, - "description": "Property organizationId.", + "description": "Property inspectionId.", "default": "", "type": "string" }, - "parentFacilityId": { + "nonConformityId": { "readOnly": true, - "description": "Property parentFacilityId.", + "description": "Property nonConformityId.", "type": [ "string", "null" ] }, - "hasChildren": { - "readOnly": true, - "description": "Property hasChildren.", - "default": false, - "type": "boolean" - }, - "equipmentCount": { - "readOnly": true, - "description": "Active equipment assigned to this facility", - "default": 0, - "type": "integer" - }, - "type": { + "fileName": { "readOnly": true, - "description": "Property type.", + "description": "Property fileName.", "default": "", "type": "string" }, - "name": { + "mimeType": { "readOnly": true, - "description": "Property name.", + "description": "Property mimeType.", "default": "", "type": "string" }, - "code": { - "readOnly": true, - "description": "Property code.", - "type": [ - "string", - "null" - ] - }, - "status": { + "size": { "readOnly": true, - "description": "Property status.", - "default": "", - "type": "string" + "description": "Property size.", + "default": 0, + "type": "integer" }, - "address": { + "label": { "readOnly": true, - "description": "Property address.", + "description": "Property label.", "type": [ "string", "null" ] }, - "latitude": { + "revision": { "readOnly": true, - "description": "Property latitude.", - "type": [ - "number", - "null" - ] + "description": "Property revision.", + "default": 1, + "type": "integer" }, - "longitude": { + "uploadedAt": { "readOnly": true, - "description": "Property longitude.", - "type": [ - "number", - "null" - ] + "description": "Property uploadedAt.", + "default": "", + "type": "string" + } + } + } + ], + "description": "File attachments linked to an inspection or a non-conformity." + }, + "InspectionResponse.CreateInspectionResponseInput": { + "type": "object", + "description": "Resource InspectionResponseResource.", + "required": [ + "organization", + "inspection", + "itemKey" + ], + "properties": { + "organization": { + "description": "Property organization.", + "default": "", + "type": "string" + }, + "intervention": { + "description": "Property intervention.", + "type": [ + "string", + "null" + ] + }, + "inspection": { + "description": "Property inspection.", + "default": "", + "type": "string" + }, + "clientId": { + "format": "uuid", + "description": "Property clientId.", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "type": [ + "string", + "null" + ] + }, + "itemKey": { + "maxLength": 160, + "description": "Property itemKey.", + "default": "", + "type": "string" + }, + "value": { + "description": "Property value.", + "type": [ + "string", + "null" + ] + } + } + }, + "InspectionResponse.InspectionResponseOutput.jsonld": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { + "id": { + "description": "Property id.", + "type": "string" }, - "metadata": { - "readOnly": true, - "description": "Property metadata.", - "type": "object", - "additionalProperties": { - "type": [ - "string", - "null" - ] - } + "organization": { + "description": "Property organization.", + "type": "string" }, - "levelIndex": { - "readOnly": true, - "description": "Optional stacking order of the floor (ground floor = 0, first basement = -1)", + "intervention": { + "description": "Property intervention.", "type": [ - "integer", + "string", "null" ] }, - "elevationMeters": { - "readOnly": true, - "description": "Property elevationMeters", - "type": [ - "number", - "null" - ] + "inspection": { + "description": "Property inspection.", + "type": "string" }, - "heightMeters": { - "readOnly": true, - "description": "Property heightMeters", - "type": [ - "number", - "null" - ] + "recordStatus": { + "description": "Property recordStatus.", + "type": "string" }, - "planGeometry": { - "readOnly": true, - "description": "Optional spatial geometry bound to an ancestor floor plan (detail views only)", - "type": [ - "object", - "null" - ], - "additionalProperties": { - "anyOf": [ - { - "type": "array", - "items": { - "type": "array", - "items": { - "type": "number" - } - } - }, - { - "type": "string" - } - ] - } + "revision": { + "description": "Property revision.", + "type": "integer" }, - "geometryIssue": { - "readOnly": true, - "description": "Property geometryIssue.", + "itemKey": { + "description": "Property itemKey.", + "type": "string" + }, + "value": { + "description": "Property value.", "type": [ "string", "null" - ], - "enum": [ - "invalid_geometry", - "plan_unavailable", - "outside_ancestry", - "other_plan", - null ] }, - "hierarchyIssues": { - "readOnly": true, - "description": "Property hierarchyIssues.", - "type": "array", - "items": { - "type": "string" - } - }, "createdAt": { - "readOnly": true, "description": "Property createdAt.", - "default": "", "type": "string" }, "updatedAt": { - "readOnly": true, "description": "Property updatedAt.", - "default": "", "type": "string" - }, - "path": { - "readOnly": true, - "description": "Ancestor breadcrumb, root first, excluding this facility; opt-in on collections through includePath.", - "type": "array", - "items": { - "type": "object", - "required": [ - "id", - "name", - "type" - ], - "properties": { - "id": { - "type": "string", - "format": "uuid" - }, - "name": { - "type": "string" - }, - "type": { - "type": "string", - "enum": [ - "site", - "building", - "floor", - "zone", - "area" - ] - } - } - } } } } ], - "description": "Resource CanonicalFacilityResource." + "description": "Resource InspectionResponseResource." }, - "Facility.PatchCanonicalFacilityInput.jsonMergePatch": { + "InspectionResponse.PatchInspectionResponseInput.jsonMergePatch": { + "type": "object", + "description": "Resource InspectionResponseResource.", + "properties": { + "value": { + "description": "Property value.", + "type": [ + "string", + "null" + ] + } + } + }, + "InspectorOutput.jsonld": { "type": "object", - "description": "Resource CanonicalFacilityResource.", "properties": { "type": { - "enum": [ - "site", - "building", - "floor", - "zone", - "area" - ], - "description": "Property type.", + "readOnly": true, + "description": "Property type", + "default": "", + "type": "string" + }, + "id": { + "readOnly": true, + "description": "Property id", "type": [ "string", "null" ] }, - "name": { - "minLength": 2, - "maxLength": 120, - "description": "Property name.", + "firstName": { + "readOnly": true, + "description": "Property firstName", "type": [ "string", "null" ] }, - "code": { - "maxLength": 80, - "description": "Property code.", + "lastName": { + "readOnly": true, + "description": "Property lastName", "type": [ "string", "null" ] }, - "address": { - "maxLength": 255, - "description": "Property address.", + "displayName": { + "readOnly": true, + "description": "Property displayName", + "default": "", + "type": "string" + }, + "avatarUrl": { + "readOnly": true, + "description": "Property avatarUrl", "type": [ "string", "null" ] }, - "latitude": { - "minimum": -90, - "maximum": 90, - "description": "Property latitude.", + "organizationName": { + "readOnly": true, + "description": "Property organizationName", "type": [ - "number", + "string", "null" ] + } + } + }, + "InspectorOutput.jsonld-inspection.read": { + "type": "object", + "properties": { + "type": { + "readOnly": true, + "description": "Property type", + "default": "", + "type": "string" }, - "longitude": { - "minimum": -180, - "maximum": 180, - "description": "Property longitude.", + "id": { + "readOnly": true, + "description": "Property id", "type": [ - "number", + "string", "null" ] }, - "metadata": { - "description": "Property metadata.", + "firstName": { + "readOnly": true, + "description": "Property firstName", "type": [ - "object", + "string", "null" - ], - "additionalProperties": { - "type": [ - "string", - "null" - ] - } + ] }, - "status": { + "lastName": { + "readOnly": true, + "description": "Property lastName", + "type": [ + "string", + "null" + ] + }, + "displayName": { + "readOnly": true, + "description": "Property displayName", + "default": "", + "type": "string" + }, + "avatarUrl": { + "readOnly": true, + "description": "Property avatarUrl", + "type": [ + "string", + "null" + ] + }, + "organizationName": { + "readOnly": true, + "description": "Property organizationName", + "type": [ + "string", + "null" + ] + } + } + }, + "Intervention.CreateInterventionInput": { + "type": "object", + "description": "Field intervention coordinating draft operational resources.", + "required": [ + "organization", + "name" + ], + "properties": { + "organization": { + "description": "Property organization.", + "default": "", + "example": "/api/organizations/550e8400-e29b-41d4-a716-446655440000", + "type": "string" + }, + "type": { "enum": [ - "active", - "archived" + "site_setup", + "inventory", + "inspection_campaign", + "preventive_maintenance", + "corrective_maintenance" ], - "description": "Property status.", + "description": "Property type.", + "default": "site_setup", + "type": "string" + }, + "name": { + "maxLength": 160, + "description": "Property name.", + "default": "", + "type": "string" + }, + "description": { + "maxLength": 5000, + "description": "Property description.", "type": [ "string", "null" ] }, - "parent": { - "description": "Property parent.", + "site": { + "description": "Property site.", "type": [ "string", "null" ] }, - "levelIndex": { - "minimum": -100, - "maximum": 200, - "description": "Property levelIndex.", + "responsible": { + "description": "Property responsible.", "type": [ - "integer", + "string", "null" ] }, - "elevationMeters": { - "minimum": -10000, - "maximum": 10000, - "description": "Property elevationMeters", + "participants": { + "description": "Property participants.", + "type": "array", + "items": { + "type": "string" + } + }, + "priority": { + "enum": [ + "low", + "normal", + "high", + "urgent" + ], + "description": "Property priority.", + "default": "normal", + "type": "string" + }, + "plannedStartAt": { + "description": "Property plannedStartAt.", + "externalDocs": { + "url": "https://schema.org/DateTime" + }, "type": [ - "number", + "string", "null" ] }, - "heightMeters": { - "minimum": 0, - "exclusiveMinimum": 0, - "maximum": 1000, - "description": "Property heightMeters", + "dueAt": { + "description": "Property dueAt.", + "externalDocs": { + "url": "https://schema.org/DateTime" + }, "type": [ - "number", + "string", "null" ] + }, + "labelIds": { + "description": "Property labelIds.", + "type": "array", + "items": { + "type": "string" + } } } }, - "FacilityAttachment.FacilityAttachmentOutput.jsonld-facility.read": { + "Intervention.InterventionOutput.jsonld": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -51811,403 +65374,315 @@ { "type": "object", "properties": { + "closureSnapshot": { + "description": "Property closureSnapshot. Versioned dossier frozen by atomic publication.", + "type": [ + "object", + "null" + ], + "additionalProperties": { + "type": [ + "string", + "null" + ] + } + }, "id": { - "readOnly": true, "description": "Property id.", "default": "", "type": "string" }, - "facilityId": { - "readOnly": true, - "description": "Property facilityId.", + "organization": { + "description": "Property organization.", "default": "", "type": "string" }, - "fileName": { - "readOnly": true, - "description": "Property fileName.", - "default": "", + "number": { + "description": "Property number.", + "default": 0, + "type": "integer" + }, + "type": { + "description": "Property type.", + "default": "site_setup", "type": "string" }, - "mimeType": { - "readOnly": true, - "description": "Property mimeType.", + "name": { + "description": "Property name.", "default": "", "type": "string" }, - "size": { - "readOnly": true, - "description": "Property size.", - "default": 0, - "type": "integer" - }, - "label": { - "readOnly": true, - "description": "Property label.", + "description": { + "description": "Property description.", "type": [ "string", "null" ] }, - "revision": { - "readOnly": true, - "description": "Property revision.", - "default": 1, - "type": "integer" - }, - "kind": { - "readOnly": true, - "description": "Property kind.", - "default": "document", + "status": { + "description": "Property status.", + "default": "draft", "type": "string" }, - "isPrimaryPlan": { - "readOnly": true, - "description": "Property isPrimaryPlan.", - "default": false, - "type": "boolean" - }, - "imageWidth": { - "readOnly": true, - "description": "Property imageWidth.", + "site": { + "description": "Property site.", "type": [ - "integer", + "string", "null" ] }, - "imageHeight": { - "readOnly": true, - "description": "Property imageHeight.", + "responsible": { + "description": "Property responsible.", "type": [ - "integer", + "string", "null" ] }, - "uploadedAt": { - "readOnly": true, - "description": "Property uploadedAt.", - "default": "", + "participants": { + "description": "Property participants.", + "type": "array", + "items": { + "type": "string" + } + }, + "priority": { + "description": "Property priority.", + "default": "normal", "type": "string" }, - "calibration": { - "readOnly": true, - "description": "Metric calibration of the original plan image.", + "plannedStartAt": { + "description": "Property plannedStartAt.", "type": [ - "object", + "string", "null" - ], - "required": [ - "widthMeters", - "rotationDegrees", - "offsetXMeters", - "offsetZMeters" - ], - "properties": { - "widthMeters": { - "type": "number", - "exclusiveMinimum": 0, - "maximum": 100000 - }, - "rotationDegrees": { - "type": "number", - "minimum": -360, - "maximum": 360 - }, - "offsetXMeters": { - "type": "number", - "minimum": -100000, - "maximum": 100000 - }, - "offsetZMeters": { - "type": "number", - "minimum": -100000, - "maximum": 100000 - } - }, - "additionalProperties": false + ] }, - "calibrationBuildingId": { - "readOnly": true, - "description": "Property calibrationBuildingId", + "dueAt": { + "description": "Property dueAt.", "type": [ "string", "null" - ], - "format": "uuid" + ] }, - "calibrationIssue": { - "readOnly": true, - "description": "Property calibrationIssue", + "reviewNote": { + "description": "Property reviewNote.", "type": [ "string", "null" - ], - "enum": [ - "building_changed", - "unverified_frame", - null ] - } - } - } - ], - "description": "File attachments linked to a facility." - }, - "FacilityEquipmentSummary.FacilityEquipmentSummaryOutput.jsonld": { - "allOf": [ - { - "$ref": "#/components/schemas/HydraItemBaseSchema" - }, - { - "type": "object", - "properties": { - "scope": { - "readOnly": true, - "description": "Property scope.", - "default": "subtree", - "type": "string", - "enum": [ - "subtree", - "direct" + }, + "revision": { + "description": "Property revision.", + "default": 1, + "type": "integer" + }, + "recurrence": { + "description": "Property recurrence.", + "type": [ + "string", + "null" ] }, - "totalItems": { - "readOnly": true, - "description": "Property totalItems.", - "default": 0, - "type": "integer", - "minimum": 0 + "allowedTransitions": { + "description": "Property allowedTransitions.", + "type": "array", + "items": { + "type": "string" + } }, - "byStatus": { - "readOnly": true, - "description": "Property byStatus.", - "default": { - "in_stock": 0, - "operational": 0, - "under_maintenance": 0, - "decommissioned": 0 - }, - "type": "object", - "required": [ - "in_stock", - "operational", - "under_maintenance", - "decommissioned" - ], - "properties": { - "in_stock": { - "type": "integer", - "minimum": 0 - }, - "operational": { - "type": "integer", - "minimum": 0 - }, - "under_maintenance": { - "type": "integer", - "minimum": 0 + "allowedActions": { + "description": "Property allowedActions.", + "anyOf": [ + { + "$ref": "#/components/schemas/InterventionAllowedActionsOutput.jsonld" }, - "decommissioned": { - "type": "integer", - "minimum": 0 + { + "type": "null" } - } + ] }, - "needingAttentionCount": { - "readOnly": true, - "description": "Property needingAttentionCount.", + "facilitiesCount": { + "description": "Property facilitiesCount.", "default": 0, - "type": "integer", - "minimum": 0 + "type": "integer" + }, + "equipmentCount": { + "description": "Property equipmentCount.", + "default": 0, + "type": "integer" + }, + "inspectionsCount": { + "description": "Property inspectionsCount.", + "default": 0, + "type": "integer" + }, + "blockersCount": { + "description": "Property blockersCount.", + "default": 0, + "type": "integer" + }, + "workItemsCount": { + "description": "Property workItemsCount.", + "default": 0, + "type": "integer" + }, + "completedWorkItemsCount": { + "description": "Property completedWorkItemsCount.", + "default": 0, + "type": "integer" + }, + "proposedChangesCount": { + "description": "Property proposedChangesCount.", + "default": 0, + "type": "integer" + }, + "commentsCount": { + "description": "Property commentsCount.", + "default": 0, + "type": "integer" + }, + "hasSignature": { + "description": "Property hasSignature.", + "default": false, + "type": "boolean" + }, + "labels": { + "description": "Property labels.", + "type": "array", + "items": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "createdAt": { + "description": "Property createdAt.", + "default": "", + "type": "string" + }, + "updatedAt": { + "description": "Property updatedAt.", + "default": "", + "type": "string" } } } ], - "description": "Resource FacilityEquipmentSummaryResource." + "description": "Field intervention coordinating draft operational resources." }, - "FacilityMetadataField.CreateFacilityMetadataFieldInput-facility.write": { + "Intervention.UpdateInterventionInput.jsonMergePatch": { "type": "object", - "description": "Organization-defined typed schema for facility metadata.", - "required": [ - "key", - "label", - "fieldType" - ], + "description": "Field intervention coordinating draft operational resources.", "properties": { - "key": { - "minLength": 2, + "workloadConfirmationToken": { "maxLength": 64, - "description": "Machine key, kebab-case or snake_case", - "default": "", - "example": "surface-m2", - "type": "string" + "description": "Property workloadConfirmationToken.", + "type": [ + "string", + "null" + ] }, - "label": { - "minLength": 2, - "maxLength": 80, - "description": "Human-readable label", - "default": "", - "example": "Surface (m²)", - "type": "string" + "name": { + "minLength": 1, + "maxLength": 160, + "description": "Property name.", + "type": [ + "string", + "null" + ] }, - "fieldType": { + "description": { + "maxLength": 5000, + "description": "Property description.", + "type": [ + "string", + "null" + ] + }, + "status": { "enum": [ - "text", - "number", - "date", - "boolean", - "select" + "draft", + "planned", + "in_progress", + "submitted", + "changes_requested", + "published", + "abandoned" ], - "description": "Field type", - "default": "", - "example": "number", - "type": "string" + "description": "Property status.", + "type": [ + "string", + "null" + ] }, - "required": { - "description": "Whether the field is required on facility creation", - "default": false, - "type": "boolean" + "site": { + "description": "Property site.", + "type": [ + "string", + "null" + ] }, - "options": { - "description": "Select options, required only when fieldType is \"select\"", - "example": [ - "ERP", - "IGH", - "Habitation" + "responsible": { + "description": "Property responsible.", + "type": [ + "string", + "null" + ] + }, + "participants": { + "description": "Property participants.", + "type": [ + "array", + "null" ], - "type": "array", "items": { "type": "string" } }, - "facilityType": { + "priority": { "enum": [ - "site", - "building", - "floor", - "zone", - "area" + "low", + "normal", + "high", + "urgent" ], - "description": "Optional facility type scope; omitted or null applies to every type", - "example": "building", + "description": "Property priority.", "type": [ "string", "null" ] }, - "unit": { - "maxLength": 16, - "description": "Optional unit label", - "example": "m²", + "plannedStartAt": { + "description": "Property plannedStartAt.", + "externalDocs": { + "url": "https://schema.org/DateTime" + }, "type": [ "string", "null" ] - } - } - }, - "FacilityMetadataField.FacilityMetadataFieldOutput.jsonld-facility.read": { - "allOf": [ - { - "$ref": "#/components/schemas/HydraItemBaseSchema" }, - { - "type": "object", - "properties": { - "id": { - "readOnly": true, - "description": "Property id", - "default": "", - "type": "string" - }, - "key": { - "readOnly": true, - "description": "Property key", - "default": "", - "type": "string" - }, - "label": { - "readOnly": true, - "description": "Property label", - "default": "", - "type": "string" - }, - "fieldType": { - "readOnly": true, - "description": "Property fieldType", - "default": "", - "type": "string" - }, - "options": { - "readOnly": true, - "type": "array", - "items": { - "type": "string" - } - }, - "facilityType": { - "readOnly": true, - "description": "Property facilityType", - "type": [ - "string", - "null" - ] - }, - "required": { - "readOnly": true, - "description": "Property required", - "default": false, - "type": "boolean" - }, - "unit": { - "readOnly": true, - "description": "Property unit", - "type": [ - "string", - "null" - ] - } - } - } - ], - "description": "Organization-defined typed schema for facility metadata." - }, - "FacilityMetadataField.UpdateFacilityMetadataFieldInput-facility.write.jsonMergePatch": { - "type": "object", - "description": "Organization-defined typed schema for facility metadata.", - "properties": { - "label": { - "minLength": 2, - "maxLength": 80, - "description": "Human-readable label (partial update)", + "dueAt": { + "description": "Property dueAt.", + "externalDocs": { + "url": "https://schema.org/DateTime" + }, "type": [ "string", "null" ] }, - "fieldType": { - "enum": [ - "text", - "number", - "date", - "boolean", - "select" - ], - "description": "Field type (partial update)", + "reviewNote": { + "maxLength": 4000, + "description": "Property reviewNote.", "type": [ "string", "null" ] }, - "required": { - "description": "Whether the field is required on facility creation (partial update)", - "type": [ - "boolean", - "null" - ] - }, - "options": { - "description": "Select options (partial update)", + "labelIds": { + "description": "Property labelIds.", "type": [ "array", "null" @@ -52215,24 +65690,25 @@ "items": { "type": "string" } + } + } + }, + "InterventionActivity.CreateInterventionCommentInput": { + "type": "object", + "description": "Resource InterventionActivityResource.\n\nThe intervention activity feed: member-authored comments and\nsystem-recorded lifecycle events (creation, status transitions).", + "required": [ + "body" + ], + "properties": { + "body": { + "maxLength": 20000, + "description": "Property body.", + "default": "", + "type": "string" }, - "facilityType": { - "enum": [ - "site", - "building", - "floor", - "zone", - "area" - ], - "description": "Optional facility type scope (partial update); null applies to every type", - "type": [ - "string", - "null" - ] - }, - "unit": { - "maxLength": 16, - "description": "Optional unit label (partial update)", + "clientId": { + "maxLength": 64, + "description": "Property clientId.", "type": [ "string", "null" @@ -52240,7 +65716,7 @@ } } }, - "FacilityModel.FacilityModelOutput.jsonld-facility.read": { + "InterventionActivity.InterventionActivityOutput.jsonld": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -52249,134 +65725,177 @@ "type": "object", "properties": { "id": { - "description": "Method __construct.", - "type": "string" - }, - "organizationId": { - "description": "Method __construct.", + "description": "Property id.", + "default": "", "type": "string" }, - "buildingId": { - "description": "Method __construct.", + "intervention": { + "description": "Property intervention.", + "default": "", "type": "string" }, - "fileName": { - "description": "Method __construct.", + "kind": { + "description": "Property kind.", + "default": "system", "type": "string" }, - "mimeType": { - "description": "Method __construct.", + "event": { + "description": "Property event.", + "default": "created", "type": "string" }, - "fileSize": { - "description": "Method __construct.", - "type": "integer" - }, - "nodeCount": { - "description": "Method __construct.", - "type": "integer" - }, - "nodes": { - "description": "Method __construct.", - "type": "array", - "items": { - "type": "object", - "additionalProperties": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "string" - } - ] - } - } - }, - "revision": { - "description": "Method __construct.", - "type": "integer" + "actor": { + "description": "Property actor.", + "type": [ + "string", + "null" + ] }, - "active": { - "description": "Method __construct.", - "type": "boolean" + "body": { + "description": "Property body.", + "type": [ + "string", + "null" + ] }, - "transform": { - "description": "Method __construct.", - "type": "object", + "payload": { + "description": "Property payload.", + "type": [ + "object", + "null" + ], "additionalProperties": { - "anyOf": [ - { - "type": "object", - "additionalProperties": { - "type": "number" - } - }, - { - "type": "number" - } + "type": [ + "string", + "null" ] } }, - "bindings": { - "description": "Method __construct.", - "type": "array", - "items": { - "type": "object", - "additionalProperties": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "string" - } - ] - } - } - }, - "downloadUrl": { - "description": "Method __construct.", - "type": "string" - }, "createdAt": { - "description": "Method __construct.", - "type": "string" - }, - "updatedAt": { - "description": "Method __construct.", + "description": "Property createdAt.", + "default": "", "type": "string" - }, - "bindingIssues": { - "description": "Method __construct.", - "type": "array", - "items": { - "type": "object", - "required": [ - "nodeIndex", - "code" - ], - "additionalProperties": false, - "properties": { - "nodeIndex": { - "type": "integer", - "minimum": 0 - }, - "code": { - "type": "string", - "enum": [ - "target_unavailable" - ] - } - } - } } } } ], - "description": "Immutable self-contained GLB 2.0 building models; maximum 10 MiB and two models per building." + "description": "Resource InterventionActivityResource.\n\nThe intervention activity feed: member-authored comments and\nsystem-recorded lifecycle events (creation, status transitions)." }, - "FacilityPlanCalibration.FacilityAttachmentOutput.jsonld-facility.read": { + "InterventionAllowedActionsOutput.jsonld": { + "type": "object", + "properties": { + "canEditDetails": { + "description": "Property canEditDetails.", + "default": false, + "type": "boolean" + }, + "canEditSite": { + "description": "Property canEditSite.", + "default": false, + "type": "boolean" + }, + "canEditResponsible": { + "description": "Property canEditResponsible.", + "default": false, + "type": "boolean" + }, + "canEditPlanning": { + "description": "Property canEditPlanning.", + "default": false, + "type": "boolean" + }, + "canMutateWorkItems": { + "description": "Property canMutateWorkItems.", + "default": false, + "type": "boolean" + }, + "canMutateChanges": { + "description": "Property canMutateChanges.", + "default": false, + "type": "boolean" + }, + "canAssignTeam": { + "description": "Property canAssignTeam.", + "default": false, + "type": "boolean" + }, + "canManageAttachments": { + "description": "Property canManageAttachments.", + "default": false, + "type": "boolean" + }, + "canSubmit": { + "description": "Property canSubmit.", + "default": false, + "type": "boolean" + }, + "canWithdraw": { + "description": "Property canWithdraw.", + "default": false, + "type": "boolean" + }, + "canDelete": { + "description": "Property canDelete.", + "default": false, + "type": "boolean" + }, + "canPublish": { + "description": "Property canPublish.", + "default": false, + "type": "boolean" + } + } + }, + "InterventionAssigneeOutput.jsonld": { + "type": "object", + "properties": { + "member": { + "readOnly": true, + "description": "Property member.", + "default": "", + "type": "string" + }, + "userId": { + "readOnly": true, + "description": "Property userId.", + "type": [ + "string", + "null" + ] + }, + "firstName": { + "readOnly": true, + "description": "Property firstName.", + "type": [ + "string", + "null" + ] + }, + "lastName": { + "readOnly": true, + "description": "Property lastName.", + "type": [ + "string", + "null" + ] + }, + "displayName": { + "readOnly": true, + "description": "Property displayName.", + "default": "", + "type": "string" + }, + "avatarUrl": { + "readOnly": true, + "description": "Property avatarUrl.", + "type": [ + "string", + "null" + ] + } + } + }, + "InterventionAttachment.InterventionAttachmentOutput.jsonld": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -52390,9 +65909,9 @@ "default": "", "type": "string" }, - "facilityId": { + "interventionId": { "readOnly": true, - "description": "Property facilityId.", + "description": "Property interventionId.", "default": "", "type": "string" }, @@ -52422,244 +65941,171 @@ "null" ] }, - "revision": { + "workItemId": { "readOnly": true, - "description": "Property revision.", - "default": 1, - "type": "integer" + "description": "Property workItemId.", + "type": [ + "string", + "null" + ] }, "kind": { "readOnly": true, "description": "Property kind.", - "default": "document", + "default": "file", "type": "string" }, - "isPrimaryPlan": { - "readOnly": true, - "description": "Property isPrimaryPlan.", - "default": false, - "type": "boolean" - }, - "imageWidth": { - "readOnly": true, - "description": "Property imageWidth.", - "type": [ - "integer", - "null" - ] - }, - "imageHeight": { + "revision": { "readOnly": true, - "description": "Property imageHeight.", - "type": [ - "integer", - "null" - ] + "description": "Property revision.", + "default": 1, + "type": "integer" }, "uploadedAt": { "readOnly": true, "description": "Property uploadedAt.", "default": "", "type": "string" - }, - "calibration": { - "readOnly": true, - "description": "Metric calibration of the original plan image.", - "type": [ - "object", - "null" - ], - "required": [ - "widthMeters", - "rotationDegrees", - "offsetXMeters", - "offsetZMeters" - ], - "properties": { - "widthMeters": { - "type": "number", - "exclusiveMinimum": 0, - "maximum": 100000 - }, - "rotationDegrees": { - "type": "number", - "minimum": -360, - "maximum": 360 - }, - "offsetXMeters": { - "type": "number", - "minimum": -100000, - "maximum": 100000 - }, - "offsetZMeters": { - "type": "number", - "minimum": -100000, - "maximum": 100000 - } - }, - "additionalProperties": false - }, - "calibrationBuildingId": { - "readOnly": true, - "description": "Property calibrationBuildingId", - "type": [ - "string", - "null" - ], - "format": "uuid" - }, - "calibrationIssue": { - "readOnly": true, - "description": "Property calibrationIssue", - "type": [ - "string", - "null" - ], - "enum": [ - "building_changed", - "unverified_frame", - null - ] } } } ], - "description": "Resource FacilityAttachmentCalibrationResource." + "description": "Resource InterventionAttachmentResource.\n\nGeneralized multipart file attachments on an intervention. Write access\nis phase-based (see `InterventionMediaProcessor`)." }, - "FacilityPlanCalibration.SetFacilityAttachmentCalibrationInput-facility.write": { + "InterventionChange.CreateInterventionChangeInput": { "type": "object", - "description": "Resource FacilityAttachmentCalibrationResource.", + "description": "Resource InterventionChangeResource.", "required": [ - "calibration" + "intervention", + "resource", + "patch" ], "properties": { - "calibration": { - "description": "Calibration in metres, or null to remove the metric calibration.", + "intervention": { + "description": "Property intervention.", + "default": "", + "type": "string" + }, + "workItem": { + "description": "Property workItem.", "type": [ - "object", + "string", "null" - ], - "required": [ - "widthMeters", - "rotationDegrees", - "offsetXMeters", - "offsetZMeters" - ], + ] + }, + "resource": { + "description": "Property resource.", + "default": "", + "type": "string" + }, + "patch": { + "description": "Property patch.", + "type": "object", + "additionalProperties": { + "type": [ + "string", + "null" + ] + } + } + } + }, + "InterventionChange.InterventionChangeOutput.jsonld": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", "properties": { - "widthMeters": { - "type": "number", - "exclusiveMinimum": 0, - "maximum": 100000 + "id": { + "description": "Property id.", + "default": "", + "type": "string" }, - "rotationDegrees": { - "type": "number", - "minimum": -360, - "maximum": 360 + "intervention": { + "description": "Property intervention.", + "default": "", + "type": "string" + }, + "workItem": { + "description": "Property workItem.", + "type": [ + "string", + "null" + ] + }, + "resource": { + "description": "Property resource.", + "default": "", + "type": "string" + }, + "patch": { + "description": "Property patch.", + "type": "object", + "additionalProperties": { + "type": [ + "string", + "null" + ] + } + }, + "status": { + "description": "Property status.", + "default": "proposed", + "type": "string" }, - "offsetXMeters": { - "type": "number", - "minimum": -100000, - "maximum": 100000 + "revision": { + "description": "Property revision.", + "default": 1, + "type": "integer" }, - "offsetZMeters": { - "type": "number", - "minimum": -100000, - "maximum": 100000 - } - }, - "additionalProperties": true - } - } - }, - "FacilityTree.FacilityTreeOutput.jsonld": { - "allOf": [ - { - "$ref": "#/components/schemas/HydraItemBaseSchema" - }, - { - "type": "object", - "properties": { - "organizationId": { - "description": "Property organizationId.", + "createdAt": { + "description": "Property createdAt.", "default": "", "type": "string" }, - "generatedAt": { - "description": "Property generatedAt.", + "updatedAt": { + "description": "Property updatedAt.", "default": "", "type": "string" - }, - "nodes": { - "description": "Property nodes.", - "type": "array", - "items": { - "type": "object", - "additionalProperties": { - "anyOf": [ - { - "type": "array", - "items": { - "type": [ - "string", - "null" - ] - } - }, - { - "type": "number" - }, - { - "type": "integer" - }, - { - "type": "string" - }, - { - "type": "null" - } - ] - } - } } } } ], - "description": "Enriched facility hierarchy (Site/Building/Floor/Zone) with per-node equipment count and compliance verdict/rate, batched by facility-id list. Requires organization.compliance.read." + "description": "Resource InterventionChangeResource." }, - "FederatedAuth.FederatedCompleteInput": { + "InterventionChange.UpdateInterventionChangeInput.jsonMergePatch": { "type": "object", - "description": "Resource FederatedAuthResource.\n\nDeclares the Google and Microsoft sign-in, callback and connection API.", + "description": "Resource InterventionChangeResource.", "properties": { - "code": { - "maxLength": 4096, - "description": "Property code", - "type": [ - "string", - "null" - ] - }, - "error": { - "maxLength": 128, - "description": "Property error", + "patch": { + "description": "Property patch.", "type": [ - "string", + "object", "null" - ] + ], + "additionalProperties": { + "type": [ + "string", + "null" + ] + } }, - "state": { - "maxLength": 256, - "description": "Property state", + "status": { + "enum": [ + "proposed", + "rejected" + ], + "description": "Property status.", "type": [ "string", "null" ] } - }, - "required": [ - "state" - ] + } }, - "FederatedAuth.FederatedConnectionsOutput.jsonld": { + "InterventionIssue.InterventionIssueOutput.jsonld": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -52667,29 +66113,64 @@ { "type": "object", "properties": { - "password_configured": { - "description": "Property passwordConfigured.", - "type": "boolean" + "severity": { + "description": "Property severity.", + "default": "", + "type": "string" }, - "last_sign_in_method": { - "description": "Property lastSignInMethod.", + "resource": { + "description": "Property resource.", + "default": "", + "type": "string" + }, + "field": { + "description": "Property field.", "type": [ "string", "null" ] }, - "connections": { - "type": "array", - "items": { - "$ref": "#/components/schemas/FederatedConnectionOutput.jsonld" - } + "message": { + "description": "Property message.", + "default": "", + "type": "string" } } } ], - "description": "Resource FederatedAuthResource.\n\nDeclares the Google and Microsoft sign-in, callback and connection API." + "description": "Resource InterventionIssueResource." }, - "FederatedAuth.FederatedProviderOutput.jsonld": { + "InterventionLabel.CreateInterventionLabelInput": { + "type": "object", + "description": "Resource InterventionLabelResource.\n\nOrganization-scoped intervention labels: small, reusable `{name, color}`\ntags interventions can be assigned to (`Intervention.labelIds`).", + "required": [ + "organization", + "name", + "color" + ], + "properties": { + "organization": { + "description": "Property organization.", + "default": "", + "example": "/api/organizations/550e8400-e29b-41d4-a716-446655440000", + "type": "string" + }, + "name": { + "maxLength": 50, + "description": "Property name.", + "default": "", + "type": "string" + }, + "color": { + "pattern": "^(#[0-9a-fA-F]{6})$", + "description": "Property color.", + "default": "", + "example": "#3b82f6", + "type": "string" + } + } + }, + "InterventionLabel.InterventionLabelOutput.jsonld": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -52697,49 +66178,162 @@ { "type": "object", "properties": { - "provider": { - "description": "Method __construct", + "id": { + "description": "Property id.", + "default": "", "type": "string" }, - "enabled": { - "description": "Method __construct", - "type": "boolean" + "organization": { + "description": "Property organization.", + "default": "", + "type": "string" + }, + "name": { + "description": "Property name.", + "default": "", + "type": "string" + }, + "color": { + "description": "Property color.", + "default": "", + "type": "string" + }, + "createdAt": { + "description": "Property createdAt.", + "default": "", + "type": "string" + }, + "updatedAt": { + "description": "Property updatedAt.", + "default": "", + "type": "string" } } } ], - "description": "Resource FederatedAuthResource.\n\nDeclares the Google and Microsoft sign-in, callback and connection API." + "description": "Resource InterventionLabelResource.\n\nOrganization-scoped intervention labels: small, reusable `{name, color}`\ntags interventions can be assigned to (`Intervention.labelIds`)." }, - "FederatedAuth.FederatedStartInput": { + "InterventionLabel.UpdateInterventionLabelInput.jsonMergePatch": { "type": "object", - "description": "Resource FederatedAuthResource.\n\nDeclares the Google and Microsoft sign-in, callback and connection API.", + "description": "Resource InterventionLabelResource.\n\nOrganization-scoped intervention labels: small, reusable `{name, color}`\ntags interventions can be assigned to (`Intervention.labelIds`).", "properties": { - "return_url": { - "maxLength": 500, - "description": "Property returnUrl", - "default": "/", - "type": "string" + "name": { + "minLength": 1, + "maxLength": 50, + "description": "Property name.", + "type": [ + "string", + "null" + ] + }, + "color": { + "pattern": "^(#[0-9a-fA-F]{6})$", + "description": "Property color.", + "example": "#3b82f6", + "type": [ + "string", + "null" + ] } } }, - "FederatedAuth.FederatedStartOutput.jsonld": { - "allOf": [ - { - "$ref": "#/components/schemas/HydraItemBaseSchema" + "InterventionRecurrence.CreateInterventionRecurrenceInput": { + "type": "object", + "description": "Resource InterventionRecurrenceResource.\n\nOrganization-scoped recurring intervention schedules: a rule (frequency +\ninterval + anchor date, in a fixed timezone) that periodically\nmaterializes an intervention template into a real intervention draft\nthrough the recurring sweep (`MaterializeDueRecurrencesHandler`).", + "required": [ + "organization", + "template", + "name", + "anchorDate", + "timezone" + ], + "properties": { + "organization": { + "description": "Property organization.", + "default": "", + "example": "/api/organizations/550e8400-e29b-41d4-a716-446655440000", + "type": "string" }, - { - "type": "object", - "properties": { - "authorization_url": { - "description": "Method __construct", - "type": "string" - } - } + "template": { + "description": "Property template.", + "default": "", + "example": "/api/intervention-templates/550e8400-e29b-41d4-a716-446655440000", + "type": "string" + }, + "name": { + "maxLength": 160, + "description": "Property name.", + "default": "", + "type": "string" + }, + "site": { + "description": "Property site.", + "type": [ + "string", + "null" + ] + }, + "responsible": { + "description": "Property responsible.", + "type": [ + "string", + "null" + ] + }, + "frequency": { + "enum": [ + "weekly", + "monthly", + "quarterly", + "semiannual", + "annual" + ], + "description": "Property frequency.", + "default": "monthly", + "type": "string" + }, + "interval": { + "minimum": 1, + "maximum": 12, + "description": "Property interval.", + "default": 1, + "type": "integer" + }, + "anchorDate": { + "description": "Property anchorDate.", + "externalDocs": { + "url": "https://schema.org/DateTime" + }, + "default": "", + "example": "2026-01-31T00:00:00+00:00", + "type": "string" + }, + "timezone": { + "description": "Property timezone.", + "default": "UTC", + "example": "Europe/Paris", + "type": "string" + }, + "leadTimeDays": { + "minimum": 0, + "maximum": 90, + "description": "Property leadTimeDays.", + "default": 7, + "type": "integer" + }, + "endAt": { + "description": "Property endAt.", + "externalDocs": { + "url": "https://schema.org/DateTime" + }, + "type": [ + "string", + "null" + ] } - ], - "description": "Resource FederatedAuthResource.\n\nDeclares the Google and Microsoft sign-in, callback and connection API." + } }, - "FederatedAuth.LoginOutput.jsonld-token.read": { + "InterventionRecurrence.InterventionRecurrenceOutput.jsonld": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -52747,330 +66341,349 @@ { "type": "object", "properties": { - "access_token": { - "readOnly": true, - "description": "JWT access token for API authentication; absent while MFA is required", - "example": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...", + "id": { + "description": "Property id.", + "default": "", "type": "string" }, - "token_type": { - "readOnly": true, - "description": "Token type; absent while MFA is required", - "example": "Bearer", - "type": "string", - "enum": [ - "Bearer" - ], - "default": "Bearer" + "organization": { + "description": "Property organization.", + "default": "", + "type": "string" }, - "expires_in": { - "readOnly": true, - "description": "Token lifetime in seconds; absent while MFA is required", - "example": 3600, - "type": "integer", - "minimum": 1 + "template": { + "description": "Property template.", + "default": "", + "type": "string" }, - "scope": { - "readOnly": true, - "description": "Space-separated list of granted scopes", - "example": "openid profile email", - "type": "string", - "nullable": true + "name": { + "description": "Property name.", + "default": "", + "type": "string" }, - "mfa_required": { - "readOnly": true, - "description": "If true, authentication is incomplete. Use mfa_token to verify code.", - "example": true, - "type": "boolean", - "nullable": true + "site": { + "description": "Property site.", + "type": [ + "string", + "null" + ] }, - "mfa_token": { - "readOnly": true, - "description": "Temporary Pre-Auth Token (JWT) covering the partial authentication state.", - "example": "eyJ...", - "type": "string", - "nullable": true + "responsible": { + "description": "Property responsible.", + "type": [ + "string", + "null" + ] }, - "challenge_token": { - "readOnly": true, - "description": "The OTP challenge token (reference).", - "example": "abc...", - "type": "string", - "nullable": true + "frequency": { + "description": "Property frequency.", + "default": "", + "type": "string" }, - "mfa_method": { - "readOnly": true, - "description": "The delivery method for the MFA code (email, sms, totp).", - "example": "email", - "type": "string", - "enum": [ - "email", - "sms", - "totp" - ], - "nullable": true + "interval": { + "description": "Property interval.", + "default": 1, + "type": "integer" }, - "mfa_destination": { - "readOnly": true, - "description": "The masked destination where the code was sent (e.g., \"j***e@e****e.com\" or \"+336****5678\").", - "example": "j***e@e****e.com", - "type": "string", - "nullable": true + "anchorDate": { + "description": "Property anchorDate.", + "default": "", + "type": "string" }, - "mfa_resend_in": { - "readOnly": true, - "description": "Seconds until MFA code can be resent", - "example": 60, - "type": "integer", - "nullable": true + "timezone": { + "description": "Property timezone.", + "default": "", + "type": "string" }, - "return_url": { - "description": "Validated local destination returned by a federated login.", + "leadTimeDays": { + "description": "Property leadTimeDays.", + "default": 7, + "type": "integer" + }, + "nextOccurrenceAt": { + "description": "Property nextOccurrenceAt.", + "default": "", + "type": "string" + }, + "lastMaterializedAt": { + "description": "Property lastMaterializedAt.", "type": [ "string", "null" ] }, - "new_account": { - "description": "Whether a federated login provisioned the Fireguard account.", + "isActive": { + "description": "Property isActive.", + "default": true, + "type": "boolean" + }, + "endAt": { + "description": "Property endAt.", "type": [ - "boolean", + "string", "null" ] + }, + "createdAt": { + "description": "Property createdAt.", + "default": "", + "type": "string" + }, + "updatedAt": { + "description": "Property updatedAt.", + "default": "", + "type": "string" } } } - ], - "description": "Resource FederatedAuthResource.\n\nDeclares the Google and Microsoft sign-in, callback and connection API." + ], + "description": "Resource InterventionRecurrenceResource.\n\nOrganization-scoped recurring intervention schedules: a rule (frequency +\ninterval + anchor date, in a fixed timezone) that periodically\nmaterializes an intervention template into a real intervention draft\nthrough the recurring sweep (`MaterializeDueRecurrencesHandler`)." + }, + "InterventionRecurrence.UpdateInterventionRecurrenceInput.jsonMergePatch": { + "type": "object", + "description": "Resource InterventionRecurrenceResource.\n\nOrganization-scoped recurring intervention schedules: a rule (frequency +\ninterval + anchor date, in a fixed timezone) that periodically\nmaterializes an intervention template into a real intervention draft\nthrough the recurring sweep (`MaterializeDueRecurrencesHandler`).", + "properties": { + "name": { + "minLength": 1, + "maxLength": 160, + "description": "Property name.", + "type": [ + "string", + "null" + ] + }, + "site": { + "description": "Property site.", + "type": [ + "string", + "null" + ] + }, + "responsible": { + "description": "Property responsible.", + "type": [ + "string", + "null" + ] + }, + "frequency": { + "enum": [ + "weekly", + "monthly", + "quarterly", + "semiannual", + "annual" + ], + "description": "Property frequency.", + "type": [ + "string", + "null" + ] + }, + "interval": { + "minimum": 1, + "maximum": 12, + "description": "Property interval.", + "type": [ + "integer", + "null" + ] + }, + "anchorDate": { + "description": "Property anchorDate.", + "externalDocs": { + "url": "https://schema.org/DateTime" + }, + "type": [ + "string", + "null" + ] + }, + "timezone": { + "description": "Property timezone.", + "type": [ + "string", + "null" + ] + }, + "leadTimeDays": { + "minimum": 0, + "maximum": 90, + "description": "Property leadTimeDays.", + "type": [ + "integer", + "null" + ] + }, + "endAt": { + "description": "Property endAt.", + "externalDocs": { + "url": "https://schema.org/DateTime" + }, + "type": [ + "string", + "null" + ] + }, + "isActive": { + "description": "Property isActive.", + "example": false, + "type": [ + "boolean", + "null" + ] + } + } }, - "FederatedConnectionOutput.jsonld": { + "InterventionResponsibleStatisticOutput.jsonld": { "type": "object", "properties": { - "provider": { - "description": "Property provider", + "memberId": { + "description": "Property memberId.", + "default": "", "type": "string" }, - "email": { - "description": "Property email", - "type": "string" + "displayName": { + "description": "Property displayName.", + "type": [ + "string", + "null" + ] }, - "connected_at": { - "description": "Property connectedAt", + "count": { + "description": "Property count.", + "default": 0, + "type": "integer" + } + } + }, + "InterventionSiteStatisticOutput.jsonld": { + "type": "object", + "properties": { + "siteId": { + "description": "Property siteId.", + "default": "", "type": "string" }, - "last_used_at": { - "description": "Property lastUsedAt", - "type": "string" + "siteName": { + "description": "Property siteName.", + "type": [ + "string", + "null" + ] + }, + "count": { + "description": "Property count.", + "default": 0, + "type": "integer" } } }, - "Health.HealthOutput.jsonld-health.read_health.read.details": { + "InterventionStatistics.InterventionStatisticsOutput.jsonld": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" }, { "type": "object", - "required": [ - "status", - "timestamp", - "version" - ], "properties": { - "status": { - "readOnly": true, - "description": "Overall health status of the service", - "default": "", - "example": "healthy", - "type": "string", - "enum": [ - "healthy", - "degraded", - "unhealthy" - ] - }, - "timestamp": { - "readOnly": true, - "description": "Current server timestamp (ISO 8601)", - "default": "", - "example": "2026-01-30T10:15:30+00:00", - "type": "string", - "format": "date-time" - }, - "database": { - "readOnly": true, - "description": "Database connectivity status (when details are enabled)", - "example": true, - "type": "boolean", - "nullable": true + "total": { + "description": "Property total.", + "default": 0, + "type": "integer" }, - "cache": { - "readOnly": true, - "description": "Cache connectivity status (when details are enabled)", - "example": true, - "type": "boolean", - "nullable": true + "byStatus": { + "description": "Property byStatus.", + "type": "object", + "additionalProperties": { + "type": "integer" + } }, - "version": { - "readOnly": true, - "description": "Running application version", - "default": "1.0.0", - "example": "1.0.0", - "type": "string" - } - } - } - ], - "description": "Application health check" - }, - "HydraCollectionBaseSchema": { - "allOf": [ - { - "$ref": "#/components/schemas/HydraCollectionBaseSchemaNoPagination" - }, - { - "type": "object", - "properties": { - "view": { + "byPriority": { + "description": "Property byPriority.", "type": "object", - "properties": { - "@id": { - "type": "string", - "format": "iri-reference" - }, - "@type": { - "type": "string" - }, - "first": { - "type": [ - "string", - "null" - ], - "format": "iri-reference" - }, - "last": { - "type": [ - "string", - "null" - ], - "format": "iri-reference" - }, - "previous": { - "type": [ - "string", - "null" - ], - "format": "iri-reference" - }, - "next": { - "type": [ - "string", - "null" - ], - "format": "iri-reference" - } - }, - "example": { - "@id": "string", - "@type": "string", - "first": "string", - "last": "string", - "previous": "string", - "next": "string" + "additionalProperties": { + "type": "integer" } - } - } - } - ] - }, - "HydraCollectionBaseSchemaNoPagination": { - "type": "object", - "properties": { - "totalItems": { - "type": "integer", - "minimum": 0 - }, - "search": { - "type": "object", - "properties": { - "@type": { - "type": "string" }, - "template": { - "type": "string" + "overdue": { + "description": "Property overdue.", + "default": 0, + "type": "integer" }, - "variableRepresentation": { - "type": "string" + "dueSoon": { + "description": "Property dueSoon.", + "default": 0, + "type": "integer" }, - "mapping": { + "bySite": { + "description": "Property bySite.", "type": "array", "items": { - "type": "object", - "properties": { - "@type": { - "type": "string" - }, - "variable": { - "type": "string" - }, - "property": { - "type": [ - "string", - "null" - ] - }, - "required": { - "type": "boolean" - } - } + "$ref": "#/components/schemas/InterventionSiteStatisticOutput.jsonld" + } + }, + "byResponsible": { + "description": "Property byResponsible.", + "type": "array", + "items": { + "$ref": "#/components/schemas/InterventionResponsibleStatisticOutput.jsonld" } + }, + "averagePublicationDays": { + "description": "Property averagePublicationDays.", + "type": [ + "number", + "null" + ] } } } - } + ], + "description": "Whole-organization intervention statistics for the list page KPI cards and the kanban column counters." }, - "HydraItemBaseSchema": { + "InterventionTargetOutput.jsonld": { "type": "object", "properties": { - "@context": { - "oneOf": [ - { - "type": "string" - }, - { - "type": "object", - "properties": { - "@vocab": { - "type": "string" - }, - "hydra": { - "type": "string", - "enum": [ - "http://www.w3.org/ns/hydra/core#" - ] - } - }, - "required": [ - "@vocab", - "hydra" - ], - "additionalProperties": true - } - ] + "resource": { + "readOnly": true, + "description": "Property resource.", + "default": "", + "type": "string" }, - "@id": { + "kind": { + "readOnly": true, + "description": "Property kind.", + "default": "", "type": "string" }, - "@type": { + "label": { + "readOnly": true, + "description": "Property label.", + "default": "", "type": "string" } - }, + } + }, + "InterventionTeamAssignment.AssignInterventionTeamInput": { + "type": "object", + "description": "Resource InterventionTeamAssignmentResource.\n\nSnapshot-expands an organization team's active members into an\nintervention's participants list (requires\norganization.interventions.plan). Participants follow the schedule\nmutability guard {@see \\Intervention\\Domain\\Model\\Intervention\\Intervention}\nenforces, not a draft-only freeze: the assignment stays possible through\n`planned`, `in_progress` and `changes_requested`, and is refused only once\nthe intervention is `submitted` (frozen under review) or `published` /\n`abandoned` (immutable). Kept as a dedicated resource file to avoid growing\nthe large {@see InterventionResource}.", "required": [ - "@id", - "@type" - ] + "teamId" + ], + "properties": { + "teamId": { + "format": "uuid", + "description": "Property teamId.", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "default": "", + "type": "string" + } + } }, - "ImportJob.ImportJobOutput.jsonld": { + "InterventionTeamAssignment.InterventionOutput.jsonld": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -53078,10 +66691,18 @@ { "type": "object", "properties": { - "canResume": { - "description": "True only when the current member may resume and no live worker owns the job.", - "default": false, - "type": "boolean" + "closureSnapshot": { + "description": "Property closureSnapshot. Versioned dossier frozen by atomic publication.", + "type": [ + "object", + "null" + ], + "additionalProperties": { + "type": [ + "string", + "null" + ] + } }, "id": { "description": "Property id.", @@ -53093,473 +66714,310 @@ "default": "", "type": "string" }, - "kind": { - "description": "Property kind.", + "number": { + "description": "Property number.", + "default": 0, + "type": "integer" + }, + "type": { + "description": "Property type.", + "default": "site_setup", + "type": "string" + }, + "name": { + "description": "Property name.", "default": "", "type": "string" }, + "description": { + "description": "Property description.", + "type": [ + "string", + "null" + ] + }, "status": { "description": "Property status.", - "default": "", + "default": "draft", "type": "string" }, - "originalFilename": { - "description": "Property originalFilename.", - "default": "", + "site": { + "description": "Property site.", + "type": [ + "string", + "null" + ] + }, + "responsible": { + "description": "Property responsible.", + "type": [ + "string", + "null" + ] + }, + "participants": { + "description": "Property participants.", + "type": "array", + "items": { + "type": "string" + } + }, + "priority": { + "description": "Property priority.", + "default": "normal", "type": "string" }, - "dryRun": { - "description": "Property dryRun.", - "default": false, - "type": "boolean" + "plannedStartAt": { + "description": "Property plannedStartAt.", + "type": [ + "string", + "null" + ] }, - "totalRows": { - "description": "Property totalRows.", + "dueAt": { + "description": "Property dueAt.", "type": [ - "integer", + "string", "null" ] }, - "processedRows": { - "description": "Property processedRows.", + "reviewNote": { + "description": "Property reviewNote.", + "type": [ + "string", + "null" + ] + }, + "revision": { + "description": "Property revision.", + "default": 1, + "type": "integer" + }, + "recurrence": { + "description": "Property recurrence.", + "type": [ + "string", + "null" + ] + }, + "allowedTransitions": { + "description": "Property allowedTransitions.", + "type": "array", + "items": { + "type": "string" + } + }, + "allowedActions": { + "description": "Property allowedActions.", + "anyOf": [ + { + "$ref": "#/components/schemas/InterventionAllowedActionsOutput.jsonld" + }, + { + "type": "null" + } + ] + }, + "facilitiesCount": { + "description": "Property facilitiesCount.", "default": 0, "type": "integer" }, - "successfulRows": { - "description": "Property successfulRows.", + "equipmentCount": { + "description": "Property equipmentCount.", "default": 0, "type": "integer" }, - "failedRows": { - "description": "Property failedRows.", + "inspectionsCount": { + "description": "Property inspectionsCount.", "default": 0, "type": "integer" }, - "errorReport": { - "description": "Property errorReport.", - "type": "array", - "items": { - "$ref": "#/components/schemas/ImportRowErrorOutput.jsonld" - } + "blockersCount": { + "description": "Property blockersCount.", + "default": 0, + "type": "integer" + }, + "workItemsCount": { + "description": "Property workItemsCount.", + "default": 0, + "type": "integer" }, - "reportPage": { - "description": "Current report page, starting at one.", - "default": 1, + "completedWorkItemsCount": { + "description": "Property completedWorkItemsCount.", + "default": 0, "type": "integer" }, - "reportItemsPerPage": { - "description": "Maximum report rows returned per page.", - "default": 100, + "proposedChangesCount": { + "description": "Property proposedChangesCount.", + "default": 0, "type": "integer" }, - "reportTotal": { - "description": "Total confirmed report rows for this job.", + "commentsCount": { + "description": "Property commentsCount.", "default": 0, "type": "integer" }, - "reportHasNextPage": { - "description": "Whether a subsequent report page exists.", + "hasSignature": { + "description": "Property hasSignature.", "default": false, "type": "boolean" }, - "jobError": { - "description": "Property jobError.", - "type": [ - "string", - "null" - ] + "labels": { + "description": "Property labels.", + "type": "array", + "items": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } }, "createdAt": { "description": "Property createdAt.", "default": "", "type": "string" }, - "startedAt": { - "description": "Property startedAt.", - "type": [ - "string", - "null" - ] - }, - "completedAt": { - "description": "Property completedAt.", - "type": [ - "string", - "null" - ] - }, "updatedAt": { "description": "Property updatedAt.", "default": "", "type": "string" - }, - "canConfirm": { - "description": "Property canConfirm", - "default": false, - "type": "boolean" - }, - "confirmedJobId": { - "description": "Property confirmedJobId", - "type": [ - "string", - "null" - ] } } } ], - "description": "Bulk CSV import batches provisioning Equipment or Facility resources." + "description": "Resource InterventionTeamAssignmentResource.\n\nSnapshot-expands an organization team's active members into an\nintervention's participants list (requires\norganization.interventions.plan). Participants follow the schedule\nmutability guard {@see \\Intervention\\Domain\\Model\\Intervention\\Intervention}\nenforces, not a draft-only freeze: the assignment stays possible through\n`planned`, `in_progress` and `changes_requested`, and is refused only once\nthe intervention is `submitted` (frozen under review) or `published` /\n`abandoned` (immutable). Kept as a dedicated resource file to avoid growing\nthe large {@see InterventionResource}." }, - "ImportRowErrorOutput.jsonld": { + "InterventionTemplate.CreateInterventionTemplateInput": { "type": "object", + "description": "Resource InterventionTemplateResource.\n\nOrganization-scoped intervention templates: reusable blueprints (type,\npriority, defaults, planned items) instantiated into real intervention\ndrafts (without recurrence — see the module changelog for later lots).", + "required": [ + "organization", + "name" + ], "properties": { - "rowNumber": { - "description": "Property rowNumber.", - "default": 0, - "type": "integer" - }, - "column": { - "description": "Property column.", - "type": [ - "string", - "null" - ] - }, - "code": { - "description": "Property code.", + "organization": { + "description": "Property organization.", "default": "", + "example": "/api/organizations/550e8400-e29b-41d4-a716-446655440000", "type": "string" }, - "message": { - "description": "Property message.", + "name": { + "minLength": 2, + "maxLength": 160, + "description": "Property name.", "default": "", "type": "string" - } - } - }, - "ImportTemplate.ImportTemplateOutput.jsonld": { - "allOf": [ - { - "$ref": "#/components/schemas/HydraItemBaseSchema" - }, - { - "type": "object", - "properties": { - "id": { - "description": "Method __construct", - "type": "string" - }, - "filename": { - "description": "Method __construct", - "type": "string" - }, - "content": { - "description": "Method __construct", - "type": "string" - }, - "mediaType": { - "description": "Method __construct", - "type": "string" - } - } - } - ], - "description": "Resource ImportTemplateResource. Organization-scoped server-owned CSV templates." - }, - "Inbox.InboxOutput.jsonld-notification.inbox.read": { - "allOf": [ - { - "$ref": "#/components/schemas/HydraItemBaseSchema" - }, - { - "type": "object", - "properties": { - "items": { - "readOnly": true, - "description": "Property items.", - "type": "array", - "items": { - "$ref": "#/components/schemas/InboxItemOutput.jsonld-notification.inbox.read" - } - }, - "nextCursor": { - "readOnly": true, - "description": "Property nextCursor.", - "type": [ - "string", - "null" - ] - }, - "nextPageCursor": { - "readOnly": true, - "description": "Opaque composite cursor echoed unchanged as `cursor`; legacy nextCursor remains a date.", - "type": [ - "string", - "null" - ] - }, - "complete": { - "readOnly": true, - "description": "False when a source is unavailable; retry this page instead of advancing its cursor.", - "default": true, - "type": "boolean" - }, - "hasMore": { - "readOnly": true, - "description": "Property hasMore.", - "default": false, - "type": "boolean" - } - } - } - ], - "description": "Unified feed of things needing the authenticated user's attention, merged from every registered source." - }, - "Inbox.InboxUnreadCountOutput.jsonld-notification.inbox.unread_count": { - "allOf": [ - { - "$ref": "#/components/schemas/HydraItemBaseSchema" - }, - { - "type": "object", - "properties": { - "unreadCount": { - "readOnly": true, - "description": "Property unreadCount.", - "default": 0, - "type": "integer" - } - } - } - ], - "description": "Unified feed of things needing the authenticated user's attention, merged from every registered source." - }, - "InboxItemOutput.jsonld-notification.inbox.read": { - "type": "object", - "properties": { - "targetKind": { - "readOnly": true, - "description": "Optional navigation discriminator supplied by the source, preserving targetType.", - "type": [ - "string", - "null" - ] }, - "sourceType": { - "readOnly": true, - "description": "Source-owned event type for presentation, when the source supplies one.", + "description": { + "maxLength": 5000, + "description": "Property description.", "type": [ "string", "null" ] }, - "sourceKey": { - "readOnly": true, - "description": "Property sourceKey.", - "default": "", - "type": "string" - }, - "id": { - "readOnly": true, - "description": "Property id.", - "default": "", - "type": "string" - }, - "kind": { - "readOnly": true, - "description": "Property kind.", - "default": "", + "type": { + "enum": [ + "site_setup", + "inventory", + "inspection_campaign", + "preventive_maintenance", + "corrective_maintenance" + ], + "description": "Property type.", + "default": "site_setup", "type": "string" }, - "title": { - "readOnly": true, - "description": "Property title.", - "default": "", + "priority": { + "enum": [ + "low", + "normal", + "high", + "urgent" + ], + "description": "Property priority.", + "default": "normal", "type": "string" }, - "snippet": { - "readOnly": true, - "description": "Property snippet.", + "defaultSite": { + "description": "Property defaultSite.", "type": [ "string", "null" ] }, - "occurredAt": { - "readOnly": true, - "description": "Property occurredAt.", - "default": "", - "type": "string" - }, - "isRead": { - "readOnly": true, - "description": "Property isRead.", - "default": false, - "type": "boolean" + "defaultResponsible": { + "description": "Property defaultResponsible.", + "type": [ + "string", + "null" + ] }, - "organizationId": { - "readOnly": true, - "description": "Property organizationId.", + "duration": { + "description": "Property duration.", + "example": "P14D", "type": [ "string", "null" ] }, - "targetType": { - "readOnly": true, - "description": "Property targetType.", - "default": "", - "type": "string" + "labelIds": { + "description": "Property labelIds.", + "type": "array", + "items": { + "type": "string" + } }, - "targetId": { - "readOnly": true, - "description": "Property targetId.", - "default": "", - "type": "string" + "items": { + "description": "Property items.", + "type": "array", + "items": { + "$ref": "#/components/schemas/InterventionTemplateItemInput" + } } } }, - "Inspection.CreateInspectionInput": { + "InterventionTemplate.InstantiateInterventionTemplateInput": { "type": "object", - "description": "Resource CanonicalInspectionResource.", + "description": "Resource InterventionTemplateResource.\n\nOrganization-scoped intervention templates: reusable blueprints (type,\npriority, defaults, planned items) instantiated into real intervention\ndrafts (without recurrence — see the module changelog for later lots).", "properties": { - "clientId": { - "format": "uuid", - "description": "Client-generated UUID for idempotent offline creation", - "externalDocs": { - "url": "https://schema.org/identifier" - }, - "type": [ - "string", - "null" - ] - }, - "organization": { - "pattern": "^(/api/organizations/[0-9a-fA-F-]{36})$", - "description": "Organization IRI, required on the canonical endpoint", - "type": [ - "string", - "null" - ] - }, - "intervention": { - "pattern": "^(/api/interventions/[0-9a-fA-F-]{36})$", - "description": "Optional field intervention IRI", + "name": { + "minLength": 2, + "maxLength": 160, + "description": "Property name.", "type": [ "string", "null" ] }, - "equipmentId": { - "format": "uuid", - "description": "Equipment identifier", - "externalDocs": { - "url": "https://schema.org/identifier" - }, - "default": "", - "example": "550e8400-e29b-41d4-a716-446655440000", - "type": "string" - }, - "result": { - "enum": [ - "pass", - "fail", - "partial" - ], - "description": "Inspection result", - "default": "", - "example": "pass", - "type": "string" - }, - "performedAt": { - "description": "Date the inspection was performed (ISO 8601)", - "default": "", - "example": "2024-06-15T10:00:00+02:00", - "type": "string" - }, - "inspectorType": { - "enum": [ - "user", - "external" - ], - "description": "Inspector type (user or external)", - "default": "", - "example": "user", - "type": "string" - }, - "inspectorName": { - "maxLength": 255, - "description": "Inspector display name", - "default": "", - "example": "Jean Dupont", - "type": "string" - }, - "facilityId": { - "format": "uuid", - "description": "Optional facility identifier", - "externalDocs": { - "url": "https://schema.org/identifier" - }, + "site": { + "description": "Property site.", "type": [ "string", "null" ] }, - "checklistId": { - "format": "uuid", - "description": "Optional checklist identifier", - "externalDocs": { - "url": "https://schema.org/identifier" - }, + "responsible": { + "description": "Property responsible.", "type": [ "string", "null" ] }, - "inspectorUserId": { - "format": "uuid", - "description": "Optional user identifier (required for user type)", + "plannedStartAt": { + "description": "Property plannedStartAt.", "externalDocs": { - "url": "https://schema.org/identifier" + "url": "https://schema.org/DateTime" }, "type": [ "string", "null" ] - }, - "inspectorOrganizationName": { - "maxLength": 255, - "description": "Optional external organization name", - "type": [ - "string", - "null" - ] - }, - "notes": { - "maxLength": 5000, - "description": "Optional free-form notes", - "type": [ - "string", - "null" - ] - }, - "signature": { - "description": "Optional signature data", - "type": [ - "string", - "null" - ] } - }, - "required": [ - "equipmentId", - "result", - "performedAt", - "inspectorType", - "inspectorName" - ] + } }, - "Inspection.InspectionOutput.jsonld": { + "InterventionTemplate.InstantiateInterventionTemplateOutput.jsonld": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -53567,141 +67025,102 @@ { "type": "object", "properties": { - "intervention": { - "description": "Property intervention.", - "type": [ - "string", - "null" - ] - }, - "recordStatus": { - "description": "Property recordStatus.", - "default": "published", + "interventionId": { + "description": "Property interventionId.", + "default": "", "type": "string" }, - "revision": { - "description": "Property revision.", - "default": 1, + "number": { + "description": "Property number.", + "default": 0, "type": "integer" - }, + } + } + } + ], + "description": "Resource InterventionTemplateResource.\n\nOrganization-scoped intervention templates: reusable blueprints (type,\npriority, defaults, planned items) instantiated into real intervention\ndrafts (without recurrence — see the module changelog for later lots)." + }, + "InterventionTemplate.InterventionTemplateOutput.jsonld": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { "id": { - "readOnly": true, "description": "Property id.", "default": "", "type": "string" }, - "organizationId": { - "readOnly": true, - "description": "Property organizationId.", + "organization": { + "description": "Property organization.", "default": "", "type": "string" }, - "equipmentId": { - "readOnly": true, - "description": "Property equipmentId.", + "name": { + "description": "Property name.", "default": "", "type": "string" }, - "facilityId": { - "readOnly": true, - "description": "Property facilityId.", - "type": [ - "string", - "null" - ] - }, - "equipmentSerialNumber": { - "readOnly": true, - "description": "Serial number of the inspected equipment", - "type": [ - "string", - "null" - ] - }, - "facilityName": { - "readOnly": true, - "description": "Display name of the inspected facility", - "type": [ - "string", - "null" - ] - }, - "checklistName": { - "readOnly": true, - "description": "Display name of the followed checklist", - "type": [ - "string", - "null" - ] - }, - "result": { - "readOnly": true, - "description": "Property result.", - "default": "", - "type": "string" + "description": { + "description": "Property description.", + "type": [ + "string", + "null" + ] }, - "status": { - "readOnly": true, - "description": "Property status.", + "type": { + "description": "Property type.", "default": "", "type": "string" }, - "performedAt": { - "readOnly": true, - "description": "Property performedAt.", + "priority": { + "description": "Property priority.", "default": "", "type": "string" }, - "inspector": { - "readOnly": true, - "description": "Property inspector.", - "anyOf": [ - { - "$ref": "#/components/schemas/InspectorOutput.jsonld" - }, - { - "type": "null" - } - ] - }, - "checklistId": { - "readOnly": true, - "description": "Property checklistId.", + "defaultSite": { + "description": "Property defaultSite.", "type": [ "string", "null" ] }, - "notes": { - "readOnly": true, - "description": "Property notes.", + "defaultResponsible": { + "description": "Property defaultResponsible.", "type": [ "string", "null" ] }, - "signature": { - "readOnly": true, - "description": "Property signature.", + "duration": { + "description": "Property duration.", "type": [ "string", "null" ] }, - "nonConformitiesCount": { - "readOnly": true, - "description": "Property nonConformitiesCount.", - "default": 0, - "type": "integer" + "labelIds": { + "description": "Property labelIds.", + "type": "array", + "items": { + "type": "string" + } + }, + "items": { + "description": "Property items.", + "type": "array", + "items": { + "$ref": "#/components/schemas/InterventionTemplateItemOutput.jsonld" + } }, "createdAt": { - "readOnly": true, "description": "Property createdAt.", "default": "", "type": "string" }, "updatedAt": { - "readOnly": true, "description": "Property updatedAt.", "default": "", "type": "string" @@ -53709,169 +67128,193 @@ } } ], - "description": "Resource CanonicalInspectionResource." + "description": "Resource InterventionTemplateResource.\n\nOrganization-scoped intervention templates: reusable blueprints (type,\npriority, defaults, planned items) instantiated into real intervention\ndrafts (without recurrence — see the module changelog for later lots)." }, - "Inspection.PatchCanonicalInspectionInput.jsonMergePatch": { + "InterventionTemplate.UpdateInterventionTemplateInput.jsonMergePatch": { "type": "object", - "description": "Resource CanonicalInspectionResource.", + "description": "Resource InterventionTemplateResource.\n\nOrganization-scoped intervention templates: reusable blueprints (type,\npriority, defaults, planned items) instantiated into real intervention\ndrafts (without recurrence — see the module changelog for later lots).", "properties": { - "result": { + "name": { + "minLength": 2, + "maxLength": 160, + "description": "Property name.", + "type": [ + "string", + "null" + ] + }, + "description": { + "maxLength": 5000, + "description": "Property description.", + "type": [ + "string", + "null" + ] + }, + "type": { "enum": [ - "pass", - "fail", - "partial" + "site_setup", + "inventory", + "inspection_campaign", + "preventive_maintenance", + "corrective_maintenance" ], - "description": "Property result.", + "description": "Property type.", "type": [ "string", "null" ] }, - "status": { + "priority": { "enum": [ - "draft", - "submitted", - "closed" + "low", + "normal", + "high", + "urgent" ], - "description": "Property status.", + "description": "Property priority.", "type": [ "string", "null" ] }, - "notes": { - "maxLength": 5000, - "description": "Property notes.", + "defaultSite": { + "description": "Property defaultSite.", "type": [ "string", "null" ] }, - "signature": { - "description": "Property signature.", + "defaultResponsible": { + "description": "Property defaultResponsible.", "type": [ "string", "null" ] - } - } - }, - "InspectionAttachment.InspectionAttachmentOutput.jsonld-inspection.read": { - "allOf": [ - { - "$ref": "#/components/schemas/HydraItemBaseSchema" }, - { - "type": "object", - "properties": { - "id": { - "readOnly": true, - "description": "Property id.", - "default": "", - "type": "string" - }, - "inspectionId": { - "readOnly": true, - "description": "Property inspectionId.", - "default": "", - "type": "string" - }, - "nonConformityId": { - "readOnly": true, - "description": "Property nonConformityId.", - "type": [ - "string", - "null" - ] - }, - "fileName": { - "readOnly": true, - "description": "Property fileName.", - "default": "", - "type": "string" - }, - "mimeType": { - "readOnly": true, - "description": "Property mimeType.", - "default": "", - "type": "string" - }, - "size": { - "readOnly": true, - "description": "Property size.", - "default": 0, - "type": "integer" - }, - "label": { - "readOnly": true, - "description": "Property label.", - "type": [ - "string", - "null" - ] - }, - "revision": { - "readOnly": true, - "description": "Property revision.", - "default": 1, - "type": "integer" - }, - "uploadedAt": { - "readOnly": true, - "description": "Property uploadedAt.", - "default": "", - "type": "string" - } + "duration": { + "description": "Property duration.", + "example": "P14D", + "type": [ + "string", + "null" + ] + }, + "labelIds": { + "description": "Property labelIds.", + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "items": { + "description": "Property items.", + "type": "array", + "items": { + "$ref": "#/components/schemas/InterventionTemplateItemInput" } } - ], - "description": "File attachments linked to an inspection or a non-conformity." + } }, - "InspectionResponse.CreateInspectionResponseInput": { + "InterventionTemplateItemInput": { "type": "object", - "description": "Resource InspectionResponseResource.", - "required": [ - "organization", - "inspection", - "itemKey" - ], "properties": { - "organization": { - "description": "Property organization.", + "estimatedMinutes": { + "minimum": 0, + "description": "Property estimatedMinutes.", + "type": [ + "integer", + "null" + ] + }, + "action": { + "maxLength": 60, + "description": "Property action.", "default": "", + "example": "inspection", "type": "string" }, - "intervention": { - "description": "Property intervention.", + "target": { + "description": "Property target.", "type": [ "string", "null" ] }, - "inspection": { - "description": "Property inspection.", - "default": "", - "type": "string" + "resultResource": { + "description": "Property resultResource.", + "type": [ + "string", + "null" + ] }, - "clientId": { - "format": "uuid", - "description": "Property clientId.", - "externalDocs": { - "url": "https://schema.org/identifier" - }, + "required": { + "description": "Property required.", + "default": true, + "type": "boolean" + }, + "defaultAssignee": { + "description": "Property defaultAssignee.", + "example": "/api/organizations/550e8400-e29b-41d4-a716-446655440000/members/018f0b68-6758-7a12-8a1d-3f0d97f63c13", "type": [ "string", "null" ] + } + }, + "required": [ + "action" + ] + }, + "InterventionTemplateItemOutput.jsonld": { + "type": "object", + "properties": { + "estimatedMinutes": { + "description": "Property estimatedMinutes.", + "type": [ + "integer", + "null" + ] }, - "itemKey": { - "maxLength": 160, - "description": "Property itemKey.", + "id": { + "description": "Property id.", "default": "", "type": "string" }, - "value": { - "description": "Property value.", + "position": { + "description": "Property position.", + "default": 0, + "type": "integer" + }, + "action": { + "description": "Property action.", + "default": "", + "type": "string" + }, + "target": { + "description": "Property target.", + "type": [ + "string", + "null" + ] + }, + "resultResource": { + "description": "Property resultResource.", + "type": [ + "string", + "null" + ] + }, + "required": { + "description": "Property required.", + "default": true, + "type": "boolean" + }, + "defaultAssignee": { + "description": "Property defaultAssignee.", "type": [ "string", "null" @@ -53879,7 +67322,7 @@ } } }, - "InspectionResponse.InspectionResponseOutput.jsonld": { + "InterventionTime.TimeEntryHistoryOutput.jsonld": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -53888,283 +67331,326 @@ "type": "object", "properties": { "id": { - "description": "Property id.", + "description": "Method __construct", "type": "string" }, - "organization": { - "description": "Property organization.", - "type": "string" + "versions": { + "description": "Method __construct", + "type": "array", + "items": { + "$ref": "#/components/schemas/TimeEntryVersionView.jsonld" + } }, - "intervention": { - "description": "Property intervention.", + "totalItems": { + "description": "Method __construct", + "type": "integer" + }, + "itemsPerPage": { + "description": "Method __construct", + "type": "integer" + }, + "nextBeforeRevision": { + "description": "Method __construct", "type": [ - "string", + "integer", "null" ] - }, - "inspection": { - "description": "Property inspection.", + } + } + } + ], + "description": "InterventionTimeResource." + }, + "InterventionTime.TimeEntryOutput.jsonld": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { + "id": { + "description": "stable resource identifier, retained across idempotent retries", "type": "string" }, - "recordStatus": { - "description": "Property recordStatus.", + "entry": { + "description": "independent time-entry aggregate or view", + "$ref": "#/components/schemas/TimeEntryView.jsonld" + } + } + } + ], + "description": "InterventionTimeResource." + }, + "InterventionTime.TimeJournalOutput.jsonld": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { + "workItemId": { + "description": "intervention task associated with this contribution", "type": "string" }, - "revision": { - "description": "Property revision.", + "entries": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TimeEntryView.jsonld" + } + }, + "totalItems": { + "description": "complete scoped entry count", "type": "integer" }, - "itemKey": { - "description": "Property itemKey.", - "type": "string" + "page": { + "description": "one-based requested journal page", + "type": "integer" }, - "value": { - "description": "Property value.", + "itemsPerPage": { + "description": "maximum entries in this response", + "type": "integer" + }, + "nextPage": { + "description": "next journal page, null when complete", "type": [ - "string", + "integer", "null" ] - }, - "createdAt": { - "description": "Property createdAt.", - "type": "string" - }, - "updatedAt": { - "description": "Property updatedAt.", - "type": "string" } } } ], - "description": "Resource InspectionResponseResource." + "description": "InterventionTimeResource." }, - "InspectionResponse.PatchInspectionResponseInput.jsonMergePatch": { + "InterventionTime.WriteTimeEntryInput": { "type": "object", - "description": "Resource InspectionResponseResource.", + "description": "InterventionTimeResource.", "properties": { - "value": { - "description": "Property value.", + "id": { + "format": "uuid", + "externalDocs": { + "url": "https://schema.org/identifier" + }, "type": [ "string", "null" ] - } - } - }, - "InspectorOutput.jsonld": { - "type": "object", - "properties": { - "type": { - "readOnly": true, - "description": "Property type", + }, + "memberId": { + "format": "uuid", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "type": [ + "string", + "null" + ] + }, + "workedOn": { + "externalDocs": { + "url": "https://schema.org/Date" + }, "default": "", "type": "string" }, - "id": { - "readOnly": true, - "description": "Property id", + "minutes": { + "minimum": 1, + "maximum": 1440, + "default": 0, + "type": "integer" + }, + "note": { + "maxLength": 2000, "type": [ "string", "null" ] - }, - "firstName": { - "readOnly": true, - "description": "Property firstName", + } + }, + "required": [ + "workedOn" + ] + }, + "InterventionTime.WriteTimeEntryInput.jsonMergePatch": { + "type": "object", + "description": "InterventionTimeResource.", + "properties": { + "id": { + "format": "uuid", + "externalDocs": { + "url": "https://schema.org/identifier" + }, "type": [ "string", "null" ] }, - "lastName": { - "readOnly": true, - "description": "Property lastName", + "memberId": { + "format": "uuid", + "externalDocs": { + "url": "https://schema.org/identifier" + }, "type": [ "string", "null" ] }, - "displayName": { - "readOnly": true, - "description": "Property displayName", + "workedOn": { + "externalDocs": { + "url": "https://schema.org/Date" + }, "default": "", "type": "string" }, - "avatarUrl": { - "readOnly": true, - "description": "Property avatarUrl", + "minutes": { + "minimum": 1, + "maximum": 1440, + "default": 0, + "type": "integer" + }, + "note": { + "maxLength": 2000, + "type": [ + "string", + "null" + ] + } + } + }, + "InterventionWorkItem.CreateInterventionWorkItemInput": { + "type": "object", + "description": "Resource InterventionWorkItemResource.", + "properties": { + "operationId": { + "format": "uuid", + "description": "Property operationId. Optional preventive plan identifier.", + "externalDocs": { + "url": "https://schema.org/identifier" + }, "type": [ "string", "null" ] }, - "organizationName": { - "readOnly": true, - "description": "Property organizationName", + "occurrenceId": { + "format": "uuid", + "description": "Property occurrenceId. Optional preventive occurrence identifier.", + "externalDocs": { + "url": "https://schema.org/identifier" + }, "type": [ "string", "null" ] - } - } - }, - "InspectorOutput.jsonld-inspection.read": { - "type": "object", - "properties": { - "type": { - "readOnly": true, - "description": "Property type", - "default": "", - "type": "string" }, - "id": { - "readOnly": true, - "description": "Property id", + "operationKind": { + "enum": [ + "control", + "maintenance" + ], + "description": "Property operationKind. Optional source operation kind.", "type": [ "string", "null" ] }, - "firstName": { - "readOnly": true, - "description": "Property firstName", + "workloadConfirmationToken": { + "maxLength": 64, + "description": "Property workloadConfirmationToken.", "type": [ "string", "null" ] }, - "lastName": { - "readOnly": true, - "description": "Property lastName", + "estimatedMinutes": { + "minimum": 0, + "description": "Property estimatedMinutes. Null means not estimated.", "type": [ - "string", + "integer", "null" ] }, - "displayName": { - "readOnly": true, - "description": "Property displayName", - "default": "", - "type": "string" - }, - "avatarUrl": { - "readOnly": true, - "description": "Property avatarUrl", + "workStartsOn": { + "description": "Property workStartsOn. Organization-local date.", + "externalDocs": { + "url": "https://schema.org/Date" + }, "type": [ "string", "null" ] }, - "organizationName": { - "readOnly": true, - "description": "Property organizationName", + "workEndsOn": { + "description": "Property workEndsOn. Organization-local date.", + "externalDocs": { + "url": "https://schema.org/Date" + }, "type": [ "string", "null" ] - } - } - }, - "Intervention.CreateInterventionInput": { - "type": "object", - "description": "Field intervention coordinating draft operational resources.", - "required": [ - "organization", - "name" - ], - "properties": { - "organization": { - "description": "Property organization.", + }, + "intervention": { + "description": "Property intervention.", "default": "", - "example": "/api/organizations/550e8400-e29b-41d4-a716-446655440000", "type": "string" }, - "type": { + "action": { "enum": [ "site_setup", "inventory", - "inspection_campaign" + "inspection", + "maintenance", + "repair", + "replacement" ], - "description": "Property type.", - "default": "site_setup", - "type": "string" - }, - "name": { - "maxLength": 160, - "description": "Property name.", - "default": "", + "description": "Property action.", + "default": "inventory", "type": "string" }, - "description": { - "maxLength": 5000, - "description": "Property description.", + "target": { + "description": "Property target.", "type": [ "string", "null" ] }, - "site": { - "description": "Property site.", + "resultResource": { + "description": "Property resultResource.", "type": [ "string", "null" ] }, - "responsible": { - "description": "Property responsible.", + "assignee": { + "description": "Property assignee.", "type": [ "string", "null" ] }, - "participants": { - "description": "Property participants.", - "type": "array", - "items": { - "type": "string" - } - }, - "priority": { + "source": { "enum": [ - "low", - "normal", - "high", - "urgent" + "planned", + "discovered" ], - "description": "Property priority.", - "default": "normal", + "description": "Property source.", + "default": "planned", "type": "string" }, - "plannedStartAt": { - "description": "Property plannedStartAt.", - "externalDocs": { - "url": "https://schema.org/DateTime" - }, - "type": [ - "string", - "null" - ] - }, - "dueAt": { - "description": "Property dueAt.", - "externalDocs": { - "url": "https://schema.org/DateTime" - }, - "type": [ - "string", - "null" - ] - }, - "labelIds": { - "description": "Property labelIds.", - "type": "array", - "items": { - "type": "string" - } + "required": { + "description": "Property required.", + "default": true, + "type": "boolean" } - } + }, + "required": [ + "intervention" + ] }, - "Intervention.InterventionOutput.jsonld": { + "InterventionWorkItem.InterventionWorkItemOutput.jsonld": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -54172,175 +67658,178 @@ { "type": "object", "properties": { - "id": { - "description": "Property id.", - "default": "", - "type": "string" + "operationId": { + "description": "Property operationId. Source preventive plan identity.", + "type": [ + "string", + "null" + ] }, - "organization": { - "description": "Property organization.", - "default": "", - "type": "string" + "occurrenceId": { + "description": "Property occurrenceId. Source preventive occurrence identity.", + "type": [ + "string", + "null" + ] }, - "number": { - "description": "Property number.", + "operationKind": { + "description": "Property operationKind. Control or maintenance.", + "type": [ + "string", + "null" + ] + }, + "executionResult": { + "description": "Property executionResult. Factual result and server-owned review attribution.", + "type": [ + "object", + "null" + ], + "additionalProperties": { + "type": [ + "string", + "null" + ] + } + }, + "spentMinutes": { + "description": "Property spentMinutes.", "default": 0, "type": "integer" }, - "type": { - "description": "Property type.", - "default": "site_setup", - "type": "string" - }, - "name": { - "description": "Property name.", - "default": "", - "type": "string" + "allowedActions": { + "description": "Property allowedActions.", + "default": { + "canLogTime": false, + "canManageTime": false, + "canReestimate": false, + "canReassign": false, + "canEditPlanning": false, + "canExecute": false + }, + "type": "object", + "additionalProperties": { + "type": "boolean" + } }, - "description": { - "description": "Property description.", + "estimatedMinutes": { + "description": "Property estimatedMinutes.", "type": [ - "string", + "integer", "null" ] }, - "status": { - "description": "Property status.", - "default": "draft", - "type": "string" + "remainingMinutes": { + "description": "Property remainingMinutes.", + "type": [ + "integer", + "null" + ] }, - "site": { - "description": "Property site.", + "workStartsOn": { + "description": "Property workStartsOn.", "type": [ "string", "null" ] }, - "responsible": { - "description": "Property responsible.", + "workEndsOn": { + "description": "Property workEndsOn.", "type": [ "string", "null" ] }, - "participants": { - "description": "Property participants.", - "type": "array", - "items": { - "type": "string" - } + "id": { + "description": "Property id.", + "default": "", + "type": "string" }, - "priority": { - "description": "Property priority.", - "default": "normal", + "intervention": { + "description": "Property intervention.", + "default": "", "type": "string" }, - "plannedStartAt": { - "description": "Property plannedStartAt.", + "action": { + "description": "Property action.", + "default": "", + "type": "string" + }, + "target": { + "description": "Property target.", "type": [ "string", "null" ] }, - "dueAt": { - "description": "Property dueAt.", - "type": [ - "string", - "null" + "targetSummary": { + "description": "Property targetSummary.", + "anyOf": [ + { + "$ref": "#/components/schemas/InterventionTargetOutput.jsonld" + }, + { + "type": "null" + } ] }, - "reviewNote": { - "description": "Property reviewNote.", + "resultResource": { + "description": "Property resultResource.", "type": [ "string", "null" ] }, - "revision": { - "description": "Property revision.", - "default": 1, - "type": "integer" - }, - "recurrence": { - "description": "Property recurrence.", + "assignee": { + "description": "Property assignee.", "type": [ "string", "null" ] }, - "allowedTransitions": { - "description": "Property allowedTransitions.", - "type": "array", - "items": { - "type": "string" - } - }, - "allowedActions": { - "description": "Property allowedActions.", + "assigneeProfile": { + "description": "Property assigneeProfile.", "anyOf": [ { - "$ref": "#/components/schemas/InterventionAllowedActionsOutput.jsonld" + "$ref": "#/components/schemas/InterventionAssigneeOutput.jsonld" }, { "type": "null" } ] }, - "facilitiesCount": { - "description": "Property facilitiesCount.", - "default": 0, - "type": "integer" - }, - "equipmentCount": { - "description": "Property equipmentCount.", - "default": 0, - "type": "integer" - }, - "inspectionsCount": { - "description": "Property inspectionsCount.", - "default": 0, - "type": "integer" + "source": { + "description": "Property source.", + "default": "planned", + "type": "string" }, - "blockersCount": { - "description": "Property blockersCount.", - "default": 0, - "type": "integer" + "status": { + "description": "Property status.", + "default": "planned", + "type": "string" }, - "workItemsCount": { - "description": "Property workItemsCount.", - "default": 0, - "type": "integer" + "required": { + "description": "Property required.", + "default": true, + "type": "boolean" }, - "completedWorkItemsCount": { - "description": "Property completedWorkItemsCount.", - "default": 0, - "type": "integer" + "skipReason": { + "description": "Property skipReason.", + "type": [ + "string", + "null" + ] }, - "proposedChangesCount": { - "description": "Property proposedChangesCount.", + "evidenceCount": { + "description": "Property evidenceCount.", "default": 0, "type": "integer" }, - "commentsCount": { - "description": "Property commentsCount.", - "default": 0, + "revision": { + "description": "Property revision.", + "default": 1, "type": "integer" }, - "hasSignature": { - "description": "Property hasSignature.", - "default": false, - "type": "boolean" - }, - "labels": { - "description": "Property labels.", - "type": "array", - "items": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - }, "createdAt": { "description": "Property createdAt.", "default": "", @@ -54354,146 +67843,99 @@ } } ], - "description": "Field intervention coordinating draft operational resources." + "description": "Resource InterventionWorkItemResource." }, - "Intervention.UpdateInterventionInput.jsonMergePatch": { + "InterventionWorkItem.UpdateInterventionWorkItemInput.jsonMergePatch": { "type": "object", - "description": "Field intervention coordinating draft operational resources.", + "description": "Resource InterventionWorkItemResource.", "properties": { - "workloadConfirmationToken": { - "maxLength": 64, - "description": "Property workloadConfirmationToken.", - "type": [ - "string", - "null" - ] - }, - "name": { - "minLength": 1, - "maxLength": 160, - "description": "Property name.", + "executionResult": { + "description": "Property executionResult. An execution fact staged for the normal review workflow.", "type": [ - "string", + "object", "null" - ] + ], + "additionalProperties": { + "type": [ + "string", + "null" + ] + } }, - "description": { - "maxLength": 5000, - "description": "Property description.", + "workloadConfirmationToken": { + "maxLength": 64, + "description": "Property workloadConfirmationToken.", "type": [ "string", "null" ] }, - "status": { - "enum": [ - "draft", - "planned", - "in_progress", - "submitted", - "changes_requested", - "published", - "abandoned" - ], - "description": "Property status.", + "estimatedMinutes": { + "minimum": 0, + "description": "Property estimatedMinutes. Null means not estimated.", "type": [ - "string", + "integer", "null" ] }, - "site": { - "description": "Property site.", + "workStartsOn": { + "description": "Property workStartsOn. Organization-local date.", + "externalDocs": { + "url": "https://schema.org/Date" + }, "type": [ "string", "null" ] }, - "responsible": { - "description": "Property responsible.", + "workEndsOn": { + "description": "Property workEndsOn. Organization-local date.", + "externalDocs": { + "url": "https://schema.org/Date" + }, "type": [ "string", "null" ] }, - "participants": { - "description": "Property participants.", - "type": [ - "array", - "null" - ], - "items": { - "type": "string" - } - }, - "priority": { - "enum": [ - "low", - "normal", - "high", - "urgent" - ], - "description": "Property priority.", + "remainingMinutes": { + "minimum": 0, + "description": "Property remainingMinutes. Explicit re-estimation only.", "type": [ - "string", + "integer", "null" ] }, - "plannedStartAt": { - "description": "Property plannedStartAt.", - "externalDocs": { - "url": "https://schema.org/DateTime" - }, + "resultResource": { + "description": "Property resultResource.", "type": [ "string", "null" ] }, - "dueAt": { - "description": "Property dueAt.", - "externalDocs": { - "url": "https://schema.org/DateTime" - }, + "assignee": { + "description": "Property assignee.", "type": [ "string", "null" ] }, - "reviewNote": { - "maxLength": 4000, - "description": "Property reviewNote.", + "status": { + "enum": [ + "planned", + "in_progress", + "completed", + "skipped" + ], + "description": "Property status.", "type": [ "string", "null" ] }, - "labelIds": { - "description": "Property labelIds.", - "type": [ - "array", - "null" - ], - "items": { - "type": "string" - } - } - } - }, - "InterventionActivity.CreateInterventionCommentInput": { - "type": "object", - "description": "Resource InterventionActivityResource.\n\nThe intervention activity feed: member-authored comments and\nsystem-recorded lifecycle events (creation, status transitions).", - "required": [ - "body" - ], - "properties": { - "body": { - "maxLength": 20000, - "description": "Property body.", - "default": "", - "type": "string" - }, - "clientId": { - "maxLength": 64, - "description": "Property clientId.", + "skipReason": { + "maxLength": 2000, + "description": "Property skipReason.", "type": [ "string", "null" @@ -54501,7 +67943,7 @@ } } }, - "InterventionActivity.InterventionActivityOutput.jsonld": { + "InventoryBalance.InventoryBalanceOutput.jsonld-inventory.read": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -54510,177 +67952,112 @@ "type": "object", "properties": { "id": { - "description": "Property id.", "default": "", "type": "string" }, - "intervention": { - "description": "Property intervention.", + "partId": { "default": "", "type": "string" }, - "kind": { - "description": "Property kind.", - "default": "system", + "warehouseId": { + "default": "", "type": "string" }, - "event": { - "description": "Property event.", - "default": "created", + "quantity": { + "default": "", "type": "string" }, - "actor": { - "description": "Property actor.", - "type": [ - "string", - "null" - ] - }, - "body": { - "description": "Property body.", - "type": [ - "string", - "null" + "valuation": { + "anyOf": [ + { + "$ref": "#/components/schemas/InventoryValuationOutput.jsonld-inventory.read" + }, + { + "type": "null" + } ] - }, - "payload": { - "description": "Property payload.", - "type": [ - "object", - "null" - ], - "additionalProperties": { - "type": [ - "string", - "null" - ] - } - }, - "createdAt": { - "description": "Property createdAt.", - "default": "", - "type": "string" } } } ], - "description": "Resource InterventionActivityResource.\n\nThe intervention activity feed: member-authored comments and\nsystem-recorded lifecycle events (creation, status transitions)." + "description": "Read nonnegative stock balances. @category Resource" }, - "InterventionAllowedActionsOutput.jsonld": { + "InventoryConsumption.DeclareInventoryConsumptionInput-inventory.write": { "type": "object", + "description": "Declare complete physical consumptions, retaining shortages. @category Resource", + "additionalProperties": false, + "required": [ + "clientOperationId", + "partId", + "warehouseId", + "quantity", + "interventionId", + "occurredAt" + ], "properties": { - "canEditDetails": { - "description": "Property canEditDetails.", - "default": false, - "type": "boolean" - }, - "canEditSite": { - "description": "Property canEditSite.", - "default": false, - "type": "boolean" - }, - "canEditResponsible": { - "description": "Property canEditResponsible.", - "default": false, - "type": "boolean" - }, - "canEditPlanning": { - "description": "Property canEditPlanning.", - "default": false, - "type": "boolean" - }, - "canMutateWorkItems": { - "description": "Property canMutateWorkItems.", - "default": false, - "type": "boolean" - }, - "canMutateChanges": { - "description": "Property canMutateChanges.", - "default": false, - "type": "boolean" - }, - "canAssignTeam": { - "description": "Property canAssignTeam.", - "default": false, - "type": "boolean" - }, - "canManageAttachments": { - "description": "Property canManageAttachments.", - "default": false, - "type": "boolean" - }, - "canSubmit": { - "description": "Property canSubmit.", - "default": false, - "type": "boolean" + "clientOperationId": { + "format": "uuid", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "default": "", + "type": "string" }, - "canWithdraw": { - "description": "Property canWithdraw.", - "default": false, - "type": "boolean" + "partId": { + "format": "uuid", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "default": "", + "type": "string" }, - "canDelete": { - "description": "Property canDelete.", - "default": false, - "type": "boolean" + "warehouseId": { + "format": "uuid", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "default": "", + "type": "string" }, - "canPublish": { - "description": "Property canPublish.", - "default": false, - "type": "boolean" - } - } - }, - "InterventionAssigneeOutput.jsonld": { - "type": "object", - "properties": { - "member": { - "readOnly": true, - "description": "Property member.", + "quantity": { "default": "", "type": "string" }, - "userId": { - "readOnly": true, - "description": "Property userId.", - "type": [ - "string", - "null" - ] + "interventionId": { + "format": "uuid", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "default": "", + "type": "string" }, - "firstName": { - "readOnly": true, - "description": "Property firstName.", + "workItemId": { + "format": "uuid", + "externalDocs": { + "url": "https://schema.org/identifier" + }, "type": [ "string", "null" ] }, - "lastName": { - "readOnly": true, - "description": "Property lastName.", + "equipmentId": { + "format": "uuid", + "externalDocs": { + "url": "https://schema.org/identifier" + }, "type": [ "string", "null" ] }, - "displayName": { - "readOnly": true, - "description": "Property displayName.", + "occurredAt": { "default": "", "type": "string" - }, - "avatarUrl": { - "readOnly": true, - "description": "Property avatarUrl.", - "type": [ - "string", - "null" - ] } } }, - "InterventionAttachment.InterventionAttachmentOutput.jsonld": { + "InventoryConsumption.InventoryConsumptionOutput.jsonld-inventory.read": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -54689,113 +68066,80 @@ "type": "object", "properties": { "id": { - "readOnly": true, - "description": "Property id.", "default": "", "type": "string" }, - "interventionId": { - "readOnly": true, - "description": "Property interventionId.", + "partId": { "default": "", "type": "string" }, - "fileName": { - "readOnly": true, - "description": "Property fileName.", + "warehouseId": { "default": "", "type": "string" }, - "mimeType": { - "readOnly": true, - "description": "Property mimeType.", + "quantity": { "default": "", "type": "string" }, - "size": { - "readOnly": true, - "description": "Property size.", - "default": 0, - "type": "integer" + "interventionId": { + "default": "", + "type": "string" }, - "label": { - "readOnly": true, - "description": "Property label.", + "workItemId": { "type": [ "string", "null" ] }, - "workItemId": { - "readOnly": true, - "description": "Property workItemId.", + "equipmentId": { "type": [ "string", "null" ] }, - "kind": { - "readOnly": true, - "description": "Property kind.", - "default": "file", + "actorId": { + "default": "", "type": "string" }, - "revision": { - "readOnly": true, - "description": "Property revision.", - "default": 1, - "type": "integer" + "occurredAt": { + "default": "", + "type": "string" }, - "uploadedAt": { - "readOnly": true, - "description": "Property uploadedAt.", + "status": { "default": "", "type": "string" + }, + "reason": { + "type": [ + "string", + "null" + ] + }, + "movementId": { + "type": [ + "string", + "null" + ] + }, + "late": { + "default": false, + "type": "boolean" + }, + "replayed": { + "default": false, + "type": "boolean" } } } ], - "description": "Resource InterventionAttachmentResource.\n\nGeneralized multipart file attachments on an intervention. Write access\nis phase-based (see `InterventionMediaProcessor`)." + "description": "Declare complete physical consumptions, retaining shortages. @category Resource" }, - "InterventionChange.CreateInterventionChangeInput": { + "InventoryConsumption.ReconcileInventoryConsumptionInput-inventory.write": { "type": "object", - "description": "Resource InterventionChangeResource.", - "required": [ - "intervention", - "resource", - "patch" - ], - "properties": { - "intervention": { - "description": "Property intervention.", - "default": "", - "type": "string" - }, - "workItem": { - "description": "Property workItem.", - "type": [ - "string", - "null" - ] - }, - "resource": { - "description": "Property resource.", - "default": "", - "type": "string" - }, - "patch": { - "description": "Property patch.", - "type": "object", - "additionalProperties": { - "type": [ - "string", - "null" - ] - } - } - } + "description": "Declare complete physical consumptions, retaining shortages. @category Resource", + "additionalProperties": false }, - "InterventionChange.InterventionChangeOutput.jsonld": { + "InventoryMovement.InventoryMovementOutput.jsonld-inventory.read": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -54804,93 +68148,126 @@ "type": "object", "properties": { "id": { - "description": "Property id.", "default": "", "type": "string" }, - "intervention": { - "description": "Property intervention.", + "partId": { "default": "", "type": "string" }, - "workItem": { - "description": "Property workItem.", - "type": [ - "string", - "null" - ] - }, - "resource": { - "description": "Property resource.", + "warehouseId": { "default": "", "type": "string" }, - "patch": { - "description": "Property patch.", - "type": "object", - "additionalProperties": { - "type": [ - "string", - "null" - ] - } + "kind": { + "default": "", + "type": "string" }, - "status": { - "description": "Property status.", - "default": "proposed", + "quantity": { + "default": "", "type": "string" }, - "revision": { - "description": "Property revision.", - "default": 1, - "type": "integer" + "reason": { + "default": "", + "type": "string" }, - "createdAt": { - "description": "Property createdAt.", + "actorId": { "default": "", "type": "string" }, - "updatedAt": { - "description": "Property updatedAt.", + "occurredAt": { "default": "", "type": "string" + }, + "interventionId": { + "type": [ + "string", + "null" + ] + }, + "workItemId": { + "type": [ + "string", + "null" + ] + }, + "equipmentId": { + "type": [ + "string", + "null" + ] + }, + "correctionOf": { + "type": [ + "string", + "null" + ] + }, + "sourceReceiptId": { + "type": [ + "string", + "null" + ] + }, + "late": { + "default": false, + "type": "boolean" + }, + "replayed": { + "default": false, + "type": "boolean" + }, + "valuation": { + "anyOf": [ + { + "$ref": "#/components/schemas/InventoryValuationOutput.jsonld-inventory.read" + }, + { + "type": "null" + } + ] } } } ], - "description": "Resource InterventionChangeResource." + "description": "Read immutable motivated stock movements. @category Resource" }, - "InterventionChange.UpdateInterventionChangeInput.jsonMergePatch": { + "InventoryPart.CreateInventoryPartInput-inventory.write": { "type": "object", - "description": "Resource InterventionChangeResource.", + "description": "Manage quantitative part and consumable references. @category Resource", + "additionalProperties": false, + "required": [ + "code", + "label", + "unit" + ], "properties": { - "patch": { - "description": "Property patch.", - "type": [ - "object", - "null" - ], - "additionalProperties": { - "type": [ - "string", - "null" - ] - } + "code": { + "maxLength": 100, + "default": "", + "type": "string" }, - "status": { + "label": { + "maxLength": 255, + "default": "", + "type": "string" + }, + "unit": { + "maxLength": 32, + "default": "piece", + "type": "string" + }, + "kind": { "enum": [ - "proposed", - "rejected" + "part", + "consumable" ], - "description": "Property status.", - "type": [ - "string", - "null" - ] + "default": "part", + "type": "string" } } }, - "InterventionIssue.InterventionIssueOutput.jsonld": { + "InventoryPart.InventoryPartOutput.jsonld-inventory.read": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -54898,219 +68275,206 @@ { "type": "object", "properties": { - "severity": { - "description": "Property severity.", + "id": { "default": "", "type": "string" }, - "resource": { - "description": "Property resource.", + "code": { "default": "", "type": "string" }, - "field": { - "description": "Property field.", - "type": [ - "string", - "null" - ] + "label": { + "default": "", + "type": "string" }, - "message": { - "description": "Property message.", + "unit": { + "default": "", + "type": "string" + }, + "kind": { "default": "", "type": "string" + }, + "archived": { + "default": false, + "type": "boolean" } } } ], - "description": "Resource InterventionIssueResource." + "description": "Manage quantitative part and consumable references. @category Resource" }, - "InterventionLabel.CreateInterventionLabelInput": { + "InventoryPart.PatchInventoryPartInput-inventory.write.jsonMergePatch": { "type": "object", - "description": "Resource InterventionLabelResource.\n\nOrganization-scoped intervention labels: small, reusable `{name, color}`\ntags interventions can be assigned to (`Intervention.labelIds`).", - "required": [ - "organization", - "name", - "color" - ], + "description": "Manage quantitative part and consumable references. @category Resource", + "additionalProperties": false, "properties": { - "organization": { - "description": "Property organization.", + "label": { + "minLength": 1, + "maxLength": 255, + "type": [ + "string", + "null" + ] + }, + "unit": { + "minLength": 1, + "maxLength": 32, + "type": [ + "string", + "null" + ] + }, + "archived": { + "type": [ + "boolean", + "null" + ] + } + } + }, + "InventoryValuationOutput.jsonld-inventory.read": { + "type": "object", + "properties": { + "unitCost": { + "type": [ + "string", + "null" + ] + }, + "totalValue": { + "type": [ + "string", + "null" + ] + }, + "currency": { "default": "", - "example": "/api/organizations/550e8400-e29b-41d4-a716-446655440000", "type": "string" }, - "name": { - "maxLength": 50, - "description": "Property name.", + "incomplete": { + "default": false, + "type": "boolean" + } + } + }, + "InventoryWarehouse.CreateInventoryWarehouseInput-inventory.write": { + "type": "object", + "description": "Manage warehouses. @category Resource", + "additionalProperties": false, + "required": [ + "code", + "name" + ], + "properties": { + "code": { + "maxLength": 100, "default": "", "type": "string" }, - "color": { - "pattern": "^(#[0-9a-fA-F]{6})$", - "description": "Property color.", + "name": { + "maxLength": 255, "default": "", - "example": "#3b82f6", "type": "string" } } }, - "InterventionLabel.InterventionLabelOutput.jsonld": { + "InventoryWarehouse.InventoryWarehouseOutput.jsonld-inventory.read": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" }, - { - "type": "object", - "properties": { - "id": { - "description": "Property id.", - "default": "", - "type": "string" - }, - "organization": { - "description": "Property organization.", - "default": "", - "type": "string" - }, - "name": { - "description": "Property name.", + { + "type": "object", + "properties": { + "id": { "default": "", "type": "string" }, - "color": { - "description": "Property color.", + "code": { "default": "", "type": "string" }, - "createdAt": { - "description": "Property createdAt.", + "name": { "default": "", "type": "string" }, - "updatedAt": { - "description": "Property updatedAt.", - "default": "", - "type": "string" + "archived": { + "default": false, + "type": "boolean" } } } ], - "description": "Resource InterventionLabelResource.\n\nOrganization-scoped intervention labels: small, reusable `{name, color}`\ntags interventions can be assigned to (`Intervention.labelIds`)." + "description": "Manage warehouses. @category Resource" }, - "InterventionLabel.UpdateInterventionLabelInput.jsonMergePatch": { + "InventoryWarehouse.PatchInventoryWarehouseInput-inventory.write.jsonMergePatch": { "type": "object", - "description": "Resource InterventionLabelResource.\n\nOrganization-scoped intervention labels: small, reusable `{name, color}`\ntags interventions can be assigned to (`Intervention.labelIds`).", + "description": "Manage warehouses. @category Resource", + "additionalProperties": false, "properties": { "name": { "minLength": 1, - "maxLength": 50, - "description": "Property name.", + "maxLength": 255, "type": [ "string", "null" ] }, - "color": { - "pattern": "^(#[0-9a-fA-F]{6})$", - "description": "Property color.", - "example": "#3b82f6", + "archived": { "type": [ - "string", + "boolean", "null" ] } } }, - "InterventionRecurrence.CreateInterventionRecurrenceInput": { + "Inventorycorrections.CorrectInventoryStockInput-inventory.write": { "type": "object", - "description": "Resource InterventionRecurrenceResource.\n\nOrganization-scoped recurring intervention schedules: a rule (frequency +\ninterval + anchor date, in a fixed timezone) that periodically\nmaterializes an intervention template into a real intervention draft\nthrough the recurring sweep (`MaterializeDueRecurrencesHandler`).", + "description": "Motivated stock corrections. @category Resource", + "additionalProperties": false, "required": [ - "organization", - "template", - "name", - "anchorDate", - "timezone" + "clientOperationId", + "partId", + "warehouseId", + "quantity", + "reason" ], "properties": { - "organization": { - "description": "Property organization.", - "default": "", - "example": "/api/organizations/550e8400-e29b-41d4-a716-446655440000", - "type": "string" - }, - "template": { - "description": "Property template.", + "clientOperationId": { + "format": "uuid", + "externalDocs": { + "url": "https://schema.org/identifier" + }, "default": "", - "example": "/api/intervention-templates/550e8400-e29b-41d4-a716-446655440000", "type": "string" }, - "name": { - "maxLength": 160, - "description": "Property name.", + "partId": { + "format": "uuid", + "externalDocs": { + "url": "https://schema.org/identifier" + }, "default": "", "type": "string" }, - "site": { - "description": "Property site.", - "type": [ - "string", - "null" - ] - }, - "responsible": { - "description": "Property responsible.", - "type": [ - "string", - "null" - ] - }, - "frequency": { - "enum": [ - "weekly", - "monthly", - "quarterly", - "semiannual", - "annual" - ], - "description": "Property frequency.", - "default": "monthly", - "type": "string" - }, - "interval": { - "minimum": 1, - "maximum": 12, - "description": "Property interval.", - "default": 1, - "type": "integer" - }, - "anchorDate": { - "description": "Property anchorDate.", + "warehouseId": { + "format": "uuid", "externalDocs": { - "url": "https://schema.org/DateTime" + "url": "https://schema.org/identifier" }, "default": "", - "example": "2026-01-31T00:00:00+00:00", "type": "string" }, - "timezone": { - "description": "Property timezone.", - "default": "UTC", - "example": "Europe/Paris", + "quantity": { + "default": "", "type": "string" }, - "leadTimeDays": { - "minimum": 0, - "maximum": 90, - "description": "Property leadTimeDays.", - "default": 7, - "type": "integer" + "reason": { + "maxLength": 2000, + "default": "", + "type": "string" }, - "endAt": { - "description": "Property endAt.", - "externalDocs": { - "url": "https://schema.org/DateTime" - }, + "unitCost": { "type": [ "string", "null" @@ -55118,7 +68482,7 @@ } } }, - "InterventionRecurrence.InterventionRecurrenceOutput.jsonld": { + "Inventorycorrections.InventoryMovementOutput.jsonld-inventory.read": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -55127,348 +68491,400 @@ "type": "object", "properties": { "id": { - "description": "Property id.", "default": "", "type": "string" }, - "organization": { - "description": "Property organization.", + "partId": { "default": "", "type": "string" }, - "template": { - "description": "Property template.", + "warehouseId": { "default": "", "type": "string" }, - "name": { - "description": "Property name.", + "kind": { "default": "", "type": "string" }, - "site": { - "description": "Property site.", + "quantity": { + "default": "", + "type": "string" + }, + "reason": { + "default": "", + "type": "string" + }, + "actorId": { + "default": "", + "type": "string" + }, + "occurredAt": { + "default": "", + "type": "string" + }, + "interventionId": { "type": [ "string", "null" ] }, - "responsible": { - "description": "Property responsible.", + "workItemId": { "type": [ "string", "null" ] }, - "frequency": { - "description": "Property frequency.", + "equipmentId": { + "type": [ + "string", + "null" + ] + }, + "correctionOf": { + "type": [ + "string", + "null" + ] + }, + "sourceReceiptId": { + "type": [ + "string", + "null" + ] + }, + "late": { + "default": false, + "type": "boolean" + }, + "replayed": { + "default": false, + "type": "boolean" + }, + "valuation": { + "anyOf": [ + { + "$ref": "#/components/schemas/InventoryValuationOutput.jsonld-inventory.read" + }, + { + "type": "null" + } + ] + } + } + } + ], + "description": "Motivated stock corrections. @category Resource" + }, + "Inventoryreturns.InventoryMovementOutput.jsonld-inventory.read": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { + "id": { "default": "", "type": "string" }, - "interval": { - "description": "Property interval.", - "default": 1, - "type": "integer" + "partId": { + "default": "", + "type": "string" }, - "anchorDate": { - "description": "Property anchorDate.", + "warehouseId": { "default": "", "type": "string" }, - "timezone": { - "description": "Property timezone.", + "kind": { "default": "", "type": "string" }, - "leadTimeDays": { - "description": "Property leadTimeDays.", - "default": 7, - "type": "integer" + "quantity": { + "default": "", + "type": "string" }, - "nextOccurrenceAt": { - "description": "Property nextOccurrenceAt.", + "reason": { "default": "", "type": "string" }, - "lastMaterializedAt": { - "description": "Property lastMaterializedAt.", + "actorId": { + "default": "", + "type": "string" + }, + "occurredAt": { + "default": "", + "type": "string" + }, + "interventionId": { "type": [ "string", "null" ] }, - "isActive": { - "description": "Property isActive.", - "default": true, - "type": "boolean" + "workItemId": { + "type": [ + "string", + "null" + ] }, - "endAt": { - "description": "Property endAt.", + "equipmentId": { "type": [ "string", "null" ] }, - "createdAt": { - "description": "Property createdAt.", - "default": "", - "type": "string" + "correctionOf": { + "type": [ + "string", + "null" + ] }, - "updatedAt": { - "description": "Property updatedAt.", - "default": "", - "type": "string" + "sourceReceiptId": { + "type": [ + "string", + "null" + ] + }, + "late": { + "default": false, + "type": "boolean" + }, + "replayed": { + "default": false, + "type": "boolean" + }, + "valuation": { + "anyOf": [ + { + "$ref": "#/components/schemas/InventoryValuationOutput.jsonld-inventory.read" + }, + { + "type": "null" + } + ] } } } ], - "description": "Resource InterventionRecurrenceResource.\n\nOrganization-scoped recurring intervention schedules: a rule (frequency +\ninterval + anchor date, in a fixed timezone) that periodically\nmaterializes an intervention template into a real intervention draft\nthrough the recurring sweep (`MaterializeDueRecurrencesHandler`)." + "description": "Motivated stock returns. @category Resource" }, - "InterventionRecurrence.UpdateInterventionRecurrenceInput.jsonMergePatch": { + "Inventoryreturns.ReturnInventoryConsumptionInput-inventory.write": { "type": "object", - "description": "Resource InterventionRecurrenceResource.\n\nOrganization-scoped recurring intervention schedules: a rule (frequency +\ninterval + anchor date, in a fixed timezone) that periodically\nmaterializes an intervention template into a real intervention draft\nthrough the recurring sweep (`MaterializeDueRecurrencesHandler`).", + "description": "Motivated stock returns. @category Resource", + "additionalProperties": false, + "required": [ + "clientOperationId", + "consumptionId", + "quantity", + "reason" + ], "properties": { - "name": { - "minLength": 1, - "maxLength": 160, - "description": "Property name.", + "clientOperationId": { + "format": "uuid", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "default": "", + "type": "string" + }, + "consumptionId": { + "format": "uuid", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "default": "", + "type": "string" + }, + "quantity": { + "default": "", + "type": "string" + }, + "reason": { + "maxLength": 2000, + "default": "", + "type": "string" + } + } + }, + "LegacyEquipment.AssignToFacilityInput-equipment.write": { + "type": "object", + "description": "Fire safety equipment and assets managed at organization level.", + "required": [ + "facilityId" + ], + "properties": { + "facilityId": { + "format": "uuid", + "description": "Target facility identifier", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "default": "", + "example": "550e8400-e29b-41d4-a716-446655440001", + "type": "string" + }, + "installedAt": { + "description": "Optional installation date (ISO 8601)", + "example": "2024-01-15T09:00:00+00:00", "type": [ "string", "null" ] - }, - "site": { - "description": "Property site.", + } + } + }, + "LegacyEquipment.CreateEquipmentInput-equipment.write": { + "type": "object", + "description": "Fire safety equipment and assets managed at organization level.", + "properties": { + "name": { + "maxLength": 255, "type": [ "string", "null" ] }, - "responsible": { - "description": "Property responsible.", + "assetCode": { + "maxLength": 100, "type": [ "string", "null" ] }, - "frequency": { + "criticality": { "enum": [ - "weekly", - "monthly", - "quarterly", - "semiannual", - "annual" + "low", + "medium", + "high", + "critical" ], - "description": "Property frequency.", "type": [ "string", "null" ] }, - "interval": { - "minimum": 1, - "maximum": 12, - "description": "Property interval.", - "type": [ - "integer", - "null" - ] + "technicalProperties": { + "maxItems": 50, + "type": "array", + "items": { + "type": "object", + "additionalProperties": { + "type": [ + "string", + "null" + ] + } + } }, - "anchorDate": { - "description": "Property anchorDate.", + "clientId": { + "format": "uuid", + "description": "Client-generated UUID for idempotent offline creation", "externalDocs": { - "url": "https://schema.org/DateTime" + "url": "https://schema.org/identifier" }, "type": [ "string", "null" ] }, - "timezone": { - "description": "Property timezone.", + "organization": { + "pattern": "^(/api/organizations/[0-9a-fA-F-]{36})$", + "description": "Organization IRI, required on the canonical endpoint", "type": [ "string", "null" ] }, - "leadTimeDays": { - "minimum": 0, - "maximum": 90, - "description": "Property leadTimeDays.", + "intervention": { + "pattern": "^(/api/interventions/[0-9a-fA-F-]{36})$", + "description": "Optional field intervention IRI", "type": [ - "integer", + "string", "null" ] }, - "endAt": { - "description": "Property endAt.", - "externalDocs": { - "url": "https://schema.org/DateTime" - }, + "facility": { + "pattern": "^(/api/facilities/[0-9a-fA-F-]{36})$", + "description": "Optional facility IRI", "type": [ "string", "null" ] }, - "isActive": { - "description": "Property isActive.", - "example": false, + "onboardingSessionId": { + "format": "uuid", + "externalDocs": { + "url": "https://schema.org/identifier" + }, "type": [ - "boolean", + "string", "null" ] - } - } - }, - "InterventionResponsibleStatisticOutput.jsonld": { - "type": "object", - "properties": { - "memberId": { - "description": "Property memberId.", - "default": "", - "type": "string" }, - "displayName": { - "description": "Property displayName.", + "onboardingItemKey": { "type": [ "string", "null" ] }, - "count": { - "description": "Property count.", - "default": 0, - "type": "integer" - } - } - }, - "InterventionSiteStatisticOutput.jsonld": { - "type": "object", - "properties": { - "siteId": { - "description": "Property siteId.", + "type": { + "maxLength": 32, + "description": "Equipment type", "default": "", + "example": "fire_extinguisher", "type": "string" }, - "siteName": { - "description": "Property siteName.", + "subType": { + "maxLength": 100, + "description": "Optional free-form sub-type", + "example": "CO2 6kg", "type": [ "string", "null" ] }, - "count": { - "description": "Property count.", - "default": 0, - "type": "integer" - } - } - }, - "InterventionStatistics.InterventionStatisticsOutput.jsonld": { - "allOf": [ - { - "$ref": "#/components/schemas/HydraItemBaseSchema" - }, - { - "type": "object", - "properties": { - "total": { - "description": "Property total.", - "default": 0, - "type": "integer" - }, - "byStatus": { - "description": "Property byStatus.", - "type": "object", - "additionalProperties": { - "type": "integer" - } - }, - "byPriority": { - "description": "Property byPriority.", - "type": "object", - "additionalProperties": { - "type": "integer" - } - }, - "overdue": { - "description": "Property overdue.", - "default": 0, - "type": "integer" - }, - "dueSoon": { - "description": "Property dueSoon.", - "default": 0, - "type": "integer" - }, - "bySite": { - "description": "Property bySite.", - "type": "array", - "items": { - "$ref": "#/components/schemas/InterventionSiteStatisticOutput.jsonld" - } - }, - "byResponsible": { - "description": "Property byResponsible.", - "type": "array", - "items": { - "$ref": "#/components/schemas/InterventionResponsibleStatisticOutput.jsonld" - } - }, - "averagePublicationDays": { - "description": "Property averagePublicationDays.", - "type": [ - "number", - "null" - ] - } - } - } - ], - "description": "Whole-organization intervention statistics for the list page KPI cards and the kanban column counters." - }, - "InterventionTargetOutput.jsonld": { - "type": "object", - "properties": { - "resource": { - "readOnly": true, - "description": "Property resource.", - "default": "", - "type": "string" + "brand": { + "maxLength": 100, + "description": "Optional brand name", + "example": "Sicli", + "type": [ + "string", + "null" + ] }, - "kind": { - "readOnly": true, - "description": "Property kind.", - "default": "", - "type": "string" + "model": { + "maxLength": 100, + "description": "Optional model name", + "example": "Pro 6", + "type": [ + "string", + "null" + ] }, - "label": { - "readOnly": true, - "description": "Property label.", - "default": "", - "type": "string" + "serialNumber": { + "maxLength": 100, + "description": "Optional serial number (unique per organization)", + "example": "SN-2024-001", + "type": [ + "string", + "null" + ] + }, + "locationLabel": { + "maxLength": 255, + "description": "Optional free-form location label", + "example": "Near staircase B", + "type": [ + "string", + "null" + ] } - } - }, - "InterventionTeamAssignment.AssignInterventionTeamInput": { - "type": "object", - "description": "Resource InterventionTeamAssignmentResource.\n\nSnapshot-expands an organization team's active members into an\nintervention's participants list (requires\norganization.interventions.plan). Participants follow the schedule\nmutability guard {@see \\Intervention\\Domain\\Model\\Intervention\\Intervention}\nenforces, not a draft-only freeze: the assignment stays possible through\n`planned`, `in_progress` and `changes_requested`, and is refused only once\nthe intervention is `submitted` (frozen under review) or `published` /\n`abandoned` (immutable). Kept as a dedicated resource file to avoid growing\nthe large {@see InterventionResource}.", + }, "required": [ - "teamId" - ], - "properties": { - "teamId": { - "format": "uuid", - "description": "Property teamId.", - "externalDocs": { - "url": "https://schema.org/identifier" - }, - "default": "", - "type": "string" - } - } + "type" + ] }, - "InterventionTeamAssignment.InterventionOutput.jsonld": { + "LegacyEquipment.EquipmentOutput.jsonld-equipment.read": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -55476,181 +68892,229 @@ { "type": "object", "properties": { + "name": { + "type": [ + "string", + "null" + ] + }, + "assetCode": { + "type": [ + "string", + "null" + ] + }, + "criticality": { + "type": [ + "string", + "null" + ] + }, + "technicalProperties": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": { + "type": [ + "string", + "null" + ] + } + } + }, + "predecessorEquipmentId": { + "type": [ + "string", + "null" + ] + }, + "successorEquipmentId": { + "type": [ + "string", + "null" + ] + }, + "intervention": { + "description": "Property intervention.", + "type": [ + "string", + "null" + ] + }, + "recordStatus": { + "description": "Property recordStatus.", + "default": "published", + "type": "string" + }, + "revision": { + "description": "Property revision.", + "default": 1, + "type": "integer" + }, "id": { + "readOnly": true, "description": "Property id.", "default": "", "type": "string" }, - "organization": { - "description": "Property organization.", + "organizationId": { + "readOnly": true, + "description": "Property organizationId.", "default": "", "type": "string" }, - "number": { - "description": "Property number.", - "default": 0, - "type": "integer" + "facilityId": { + "readOnly": true, + "description": "Property facilityId.", + "type": [ + "string", + "null" + ] }, "type": { + "readOnly": true, "description": "Property type.", - "default": "site_setup", - "type": "string" - }, - "name": { - "description": "Property name.", "default": "", "type": "string" }, - "description": { - "description": "Property description.", + "subType": { + "readOnly": true, + "description": "Property subType.", "type": [ "string", "null" ] }, - "status": { - "description": "Property status.", - "default": "draft", - "type": "string" - }, - "site": { - "description": "Property site.", + "brand": { + "readOnly": true, + "description": "Property brand.", "type": [ "string", "null" ] }, - "responsible": { - "description": "Property responsible.", + "model": { + "readOnly": true, + "description": "Property model.", "type": [ "string", "null" ] }, - "participants": { - "description": "Property participants.", - "type": "array", - "items": { - "type": "string" - } - }, - "priority": { - "description": "Property priority.", - "default": "normal", - "type": "string" - }, - "plannedStartAt": { - "description": "Property plannedStartAt.", + "serialNumber": { + "readOnly": true, + "description": "Property serialNumber.", "type": [ "string", "null" ] }, - "dueAt": { - "description": "Property dueAt.", + "locationLabel": { + "readOnly": true, + "description": "Property locationLabel.", "type": [ "string", "null" ] }, - "reviewNote": { - "description": "Property reviewNote.", + "facilityName": { + "readOnly": true, + "description": "Display name of the assigned facility", "type": [ "string", "null" ] }, - "revision": { - "description": "Property revision.", - "default": 1, - "type": "integer" + "status": { + "readOnly": true, + "description": "Property status.", + "default": "", + "type": "string" }, - "recurrence": { - "description": "Property recurrence.", + "installedAt": { + "readOnly": true, + "description": "Property installedAt.", "type": [ "string", "null" ] }, - "allowedTransitions": { - "description": "Property allowedTransitions.", + "commissionedAt": { + "readOnly": true, + "description": "Property commissionedAt.", + "type": [ + "string", + "null" + ] + }, + "tags": { + "readOnly": true, + "description": "Property tags.", "type": "array", "items": { - "type": "string" + "$ref": "#/components/schemas/TagOutput.jsonld-equipment.read" } }, - "allowedActions": { - "description": "Property allowedActions.", - "anyOf": [ - { - "$ref": "#/components/schemas/InterventionAllowedActionsOutput.jsonld" - }, - { - "type": "null" - } - ] - }, - "facilitiesCount": { - "description": "Property facilitiesCount.", - "default": 0, - "type": "integer" - }, - "equipmentCount": { - "description": "Property equipmentCount.", - "default": 0, - "type": "integer" - }, - "inspectionsCount": { - "description": "Property inspectionsCount.", - "default": 0, - "type": "integer" - }, - "blockersCount": { - "description": "Property blockersCount.", - "default": 0, - "type": "integer" - }, - "workItemsCount": { - "description": "Property workItemsCount.", - "default": 0, - "type": "integer" + "maintenanceDueStatus": { + "readOnly": true, + "description": "Property maintenanceDueStatus.", + "default": "unscheduled", + "type": "string" }, - "completedWorkItemsCount": { - "description": "Property completedWorkItemsCount.", - "default": 0, - "type": "integer" + "controlDueStatus": { + "readOnly": true, + "description": "Due status of control operations, independent from service operations and anomalies.", + "default": "unscheduled", + "type": "string" }, - "proposedChangesCount": { - "description": "Property proposedChangesCount.", - "default": 0, - "type": "integer" + "serviceDueStatus": { + "readOnly": true, + "description": "Due status of service operations, independent from periodic controls.", + "default": "unscheduled", + "type": "string" }, - "commentsCount": { - "description": "Property commentsCount.", - "default": 0, - "type": "integer" + "controlNextDueAt": { + "readOnly": true, + "description": "Next control deadline in ISO 8601, when scheduled.", + "type": [ + "string", + "null" + ] }, - "hasSignature": { - "description": "Property hasSignature.", - "default": false, - "type": "boolean" + "serviceNextDueAt": { + "readOnly": true, + "description": "Next service deadline in ISO 8601, when scheduled.", + "type": [ + "string", + "null" + ] }, - "labels": { - "description": "Property labels.", - "type": "array", - "items": { - "type": "object", - "additionalProperties": { - "type": "string" - } + "planPosition": { + "readOnly": true, + "description": "Position pinned on a floor plan attachment (detail read only)", + "type": [ + "object", + "null" + ], + "additionalProperties": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string" + } + ] } }, "createdAt": { + "readOnly": true, "description": "Property createdAt.", "default": "", "type": "string" }, "updatedAt": { + "readOnly": true, "description": "Property updatedAt.", "default": "", "type": "string" @@ -55658,136 +69122,728 @@ } } ], - "description": "Resource InterventionTeamAssignmentResource.\n\nSnapshot-expands an organization team's active members into an\nintervention's participants list (requires\norganization.interventions.plan). Participants follow the schedule\nmutability guard {@see \\Intervention\\Domain\\Model\\Intervention\\Intervention}\nenforces, not a draft-only freeze: the assignment stays possible through\n`planned`, `in_progress` and `changes_requested`, and is refused only once\nthe intervention is `submitted` (frozen under review) or `published` /\n`abandoned` (immutable). Kept as a dedicated resource file to avoid growing\nthe large {@see InterventionResource}." + "description": "Fire safety equipment and assets managed at organization level." }, - "InterventionTemplate.CreateInterventionTemplateInput": { + "LegacyEquipment.SetEquipmentPlanPositionInput-equipment.write": { "type": "object", - "description": "Resource InterventionTemplateResource.\n\nOrganization-scoped intervention templates: reusable blueprints (type,\npriority, defaults, planned items) instantiated into real intervention\ndrafts (without recurrence — see the module changelog for later lots).", + "description": "Fire safety equipment and assets managed at organization level.", "required": [ - "organization", - "name" + "attachmentId", + "x", + "y" ], "properties": { + "attachmentId": { + "format": "uuid", + "description": "Floor plan attachment identifier the position is bound to. Required together with x/y; use null on all three to clear.", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "example": "550e8400-e29b-41d4-a716-446655440001", + "type": [ + "string", + "null" + ] + }, + "x": { + "minimum": 0, + "maximum": 1, + "description": "Normalized x coordinate in [0, 1]. Required together with attachmentId/y; use null on all three to clear.", + "example": 0.42, + "type": [ + "number", + "null" + ] + }, + "y": { + "minimum": 0, + "maximum": 1, + "description": "Normalized y coordinate in [0, 1]. Required together with attachmentId/x; use null on all three to clear.", + "example": 0.17, + "type": [ + "number", + "null" + ] + } + } + }, + "LegacyEquipment.UpdateEquipmentInput-equipment.write.jsonMergePatch": { + "type": "object", + "description": "Fire safety equipment and assets managed at organization level.", + "properties": { + "name": { + "maxLength": 255, + "type": [ + "string", + "null" + ] + }, + "assetCode": { + "maxLength": 100, + "type": [ + "string", + "null" + ] + }, + "criticality": { + "enum": [ + "low", + "medium", + "high", + "critical" + ], + "type": [ + "string", + "null" + ] + }, + "technicalProperties": { + "maxItems": 50, + "type": "array", + "items": { + "type": "object", + "additionalProperties": { + "type": [ + "string", + "null" + ] + } + } + }, + "type": { + "maxLength": 32, + "description": "Equipment type", + "default": "", + "example": "fire_extinguisher", + "type": "string" + }, + "subType": { + "maxLength": 100, + "description": "Optional free-form sub-type", + "example": "CO2 6kg", + "type": [ + "string", + "null" + ] + }, + "brand": { + "maxLength": 100, + "description": "Optional brand name", + "example": "Sicli", + "type": [ + "string", + "null" + ] + }, + "model": { + "maxLength": 100, + "description": "Optional model name", + "example": "Pro 6", + "type": [ + "string", + "null" + ] + }, + "serialNumber": { + "maxLength": 100, + "description": "Optional serial number (unique per organization)", + "example": "SN-2024-001", + "type": [ + "string", + "null" + ] + }, + "locationLabel": { + "maxLength": 255, + "description": "Optional free-form location label", + "example": "Near staircase B", + "type": [ + "string", + "null" + ] + } + } + }, + "LegacyFacility.CreateFacilityInput-facility.write": { + "type": "object", + "description": "Generic organizational facilities (site/building/floor/zone/area).", + "properties": { + "clientId": { + "format": "uuid", + "description": "Client-generated UUID for idempotent offline creation", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "type": [ + "string", + "null" + ] + }, "organization": { - "description": "Property organization.", + "pattern": "^(/api/organizations/[0-9a-fA-F-]{36})$", + "description": "Organization IRI, required on the canonical endpoint", + "type": [ + "string", + "null" + ] + }, + "intervention": { + "pattern": "^(/api/interventions/[0-9a-fA-F-]{36})$", + "description": "Optional field intervention IRI", + "type": [ + "string", + "null" + ] + }, + "onboardingSessionId": { + "format": "uuid", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "type": [ + "string", + "null" + ] + }, + "onboardingItemKey": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "site", + "building", + "floor", + "zone", + "area" + ], + "description": "Facility type", "default": "", - "example": "/api/organizations/550e8400-e29b-41d4-a716-446655440000", + "example": "site", "type": "string" }, "name": { - "minLength": 2, - "maxLength": 160, - "description": "Property name.", + "minLength": 2, + "maxLength": 120, + "description": "Facility display name", "default": "", + "example": "Headquarters", "type": "string" }, - "description": { - "maxLength": 5000, - "description": "Property description.", + "parentFacilityId": { + "format": "uuid", + "description": "Optional parent facility identifier", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "example": "550e8400-e29b-41d4-a716-446655440001", "type": [ "string", "null" ] }, - "type": { - "enum": [ - "site_setup", - "inventory", - "inspection_campaign" - ], - "description": "Property type.", - "default": "site_setup", - "type": "string" - }, - "priority": { - "enum": [ - "low", - "normal", - "high", - "urgent" - ], - "description": "Property priority.", - "default": "normal", - "type": "string" - }, - "defaultSite": { - "description": "Property defaultSite.", + "code": { + "maxLength": 80, + "description": "Optional facility code", + "example": "SITE-PAR-001", "type": [ "string", "null" ] }, - "defaultResponsible": { - "description": "Property defaultResponsible.", + "address": { + "maxLength": 255, + "description": "Optional address", + "example": "10 rue de Rivoli, Paris", "type": [ "string", "null" ] }, - "duration": { - "description": "Property duration.", - "example": "P14D", + "latitude": { + "minimum": -90, + "maximum": 90, + "description": "Optional latitude, required together with longitude", + "example": 48.8566, "type": [ - "string", + "number", "null" ] }, - "labelIds": { - "description": "Property labelIds.", - "type": "array", - "items": { - "type": "string" + "longitude": { + "minimum": -180, + "maximum": 180, + "description": "Optional longitude, required together with latitude", + "example": 2.3522, + "type": [ + "number", + "null" + ] + }, + "metadata": { + "description": "Optional free-form metadata", + "example": { + "country": "FR", + "timezone": "Europe/Paris" + }, + "type": "object", + "additionalProperties": { + "type": [ + "string", + "null" + ] } }, - "items": { - "description": "Property items.", - "type": "array", - "items": { - "$ref": "#/components/schemas/InterventionTemplateItemInput" + "levelIndex": { + "minimum": -100, + "maximum": 200, + "description": "Optional stacking order of the floor (ground floor = 0, first basement = -1)", + "example": 0, + "type": [ + "integer", + "null" + ] + }, + "elevationMeters": { + "minimum": -10000, + "maximum": 10000, + "description": "Optional floor elevation in meters", + "example": -3, + "type": [ + "number", + "null" + ] + }, + "heightMeters": { + "minimum": 0, + "exclusiveMinimum": 0, + "maximum": 1000, + "description": "Optional floor height in meters", + "example": 3, + "type": [ + "number", + "null" + ] + }, + "customerId": { + "format": "uuid", + "description": "Internal customer identifier for a root site", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "type", + "name" + ] + }, + "LegacyFacility.DuplicateFacilitySubtreeInput-facility.write": { + "type": "object", + "description": "Generic organizational facilities (site/building/floor/zone/area).", + "properties": { + "name": { + "minLength": 2, + "maxLength": 120, + "description": "Name for the copy's root. Defaults to \"{original} (copy)\" when omitted.", + "example": "Site A (copy)", + "type": [ + "string", + "null" + ] + }, + "parentFacilityId": { + "format": "uuid", + "description": "Target parent facility identifier for the copy's root. Defaults to the source facility's own parent when omitted.", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "example": "550e8400-e29b-41d4-a716-446655440001", + "type": [ + "string", + "null" + ] + } + } + }, + "LegacyFacility.FacilityBuildingModelOutput.jsonld-facility.read": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { + "buildingId": { + "readOnly": true, + "description": "Property buildingId.", + "default": "", + "type": "string" + }, + "buildingName": { + "readOnly": true, + "description": "Property buildingName.", + "default": "", + "type": "string" + }, + "floors": { + "readOnly": true, + "description": "Property floors.", + "type": "array", + "items": { + "type": "object", + "required": [ + "facilityId", + "name", + "levelIndex", + "elevationMeters", + "heightMeters", + "status", + "hierarchyIssues", + "plan", + "outline", + "rooms", + "equipment", + "diagnostics" + ], + "properties": { + "facilityId": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "levelIndex": { + "type": [ + "integer", + "null" + ] + }, + "elevationMeters": { + "type": [ + "number", + "null" + ] + }, + "heightMeters": { + "type": [ + "number", + "null" + ] + }, + "status": { + "type": "string" + }, + "hierarchyIssues": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "missing_parent", + "invalid_parent_type", + "unpublished_parent", + "invalid_ancestor", + "cycle", + "depth_exceeded" + ] + } + }, + "plan": { + "type": [ + "object", + "null" + ], + "required": [ + "attachmentId", + "imageWidth", + "imageHeight", + "calibration", + "calibrationBuildingId", + "calibrationIssue" + ], + "properties": { + "attachmentId": { + "type": "string", + "format": "uuid" + }, + "imageWidth": { + "type": [ + "integer", + "null" + ] + }, + "imageHeight": { + "type": [ + "integer", + "null" + ] + }, + "calibrationBuildingId": { + "type": [ + "string", + "null" + ], + "format": "uuid" + }, + "calibrationIssue": { + "type": [ + "string", + "null" + ], + "enum": [ + "building_changed", + "unverified_frame", + null + ] + }, + "calibration": { + "type": [ + "object", + "null" + ], + "required": [ + "widthMeters", + "rotationDegrees", + "offsetXMeters", + "offsetZMeters" + ], + "properties": { + "widthMeters": { + "type": "number" + }, + "rotationDegrees": { + "type": "number" + }, + "offsetXMeters": { + "type": "number" + }, + "offsetZMeters": { + "type": "number" + } + } + } + } + }, + "outline": { + "type": [ + "object", + "null" + ], + "required": [ + "source", + "points" + ], + "properties": { + "source": { + "type": "string", + "enum": [ + "plan_geometry", + "rooms_bbox", + "image_rect" + ] + }, + "points": { + "type": "array", + "minItems": 3, + "items": { + "type": "array", + "minItems": 2, + "maxItems": 2, + "items": { + "type": "number", + "minimum": 0, + "maximum": 1 + } + } + } + } + }, + "rooms": { + "type": "array", + "items": { + "type": "object", + "required": [ + "facilityId", + "name", + "type", + "status", + "points" + ], + "properties": { + "facilityId": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "type": { + "type": "string" + }, + "status": { + "type": "string" + }, + "points": { + "type": "array", + "minItems": 3, + "items": { + "type": "array", + "minItems": 2, + "maxItems": 2, + "items": { + "type": "number", + "minimum": 0, + "maximum": 1 + } + } + } + } + } + }, + "equipment": { + "type": "array", + "items": { + "type": "object", + "required": [ + "equipmentId", + "facilityId", + "type", + "serialNumber", + "locationLabel", + "status", + "position", + "placementIssue" + ], + "properties": { + "equipmentId": { + "type": "string", + "format": "uuid" + }, + "facilityId": { + "type": "string", + "format": "uuid" + }, + "type": { + "type": "string" + }, + "serialNumber": { + "type": [ + "string", + "null" + ] + }, + "locationLabel": { + "type": [ + "string", + "null" + ] + }, + "status": { + "type": "string" + }, + "position": { + "type": [ + "object", + "null" + ], + "required": [ + "attachmentId", + "x", + "y" + ], + "properties": { + "attachmentId": { + "type": "string", + "format": "uuid" + }, + "x": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "y": { + "type": "number", + "minimum": 0, + "maximum": 1 + } + } + }, + "placementIssue": { + "type": [ + "string", + "null" + ], + "enum": [ + "missing_plan", + "unplaced", + "other_plan", + "invalid_position", + "outside_ancestry", + null + ] + } + } + } + }, + "diagnostics": { + "type": "object", + "required": [ + "invalidGeometryCount", + "unpositionedEquipmentCount", + "geometryIssues" + ], + "properties": { + "invalidGeometryCount": { + "type": "integer", + "minimum": 0 + }, + "unpositionedEquipmentCount": { + "type": "integer", + "minimum": 0 + }, + "geometryIssues": { + "type": "array", + "items": { + "type": "object", + "required": [ + "facilityId", + "code" + ], + "properties": { + "facilityId": { + "type": "string", + "format": "uuid" + }, + "code": { + "type": "string", + "enum": [ + "invalid_geometry", + "plan_unavailable", + "outside_ancestry", + "other_plan" + ] + } + } + } + } + } + } + } + } + } } } - } - }, - "InterventionTemplate.InstantiateInterventionTemplateInput": { - "type": "object", - "description": "Resource InterventionTemplateResource.\n\nOrganization-scoped intervention templates: reusable blueprints (type,\npriority, defaults, planned items) instantiated into real intervention\ndrafts (without recurrence — see the module changelog for later lots).", - "properties": { - "name": { - "minLength": 2, - "maxLength": 160, - "description": "Property name.", - "type": [ - "string", - "null" - ] - }, - "site": { - "description": "Property site.", - "type": [ - "string", - "null" - ] - }, - "responsible": { - "description": "Property responsible.", - "type": [ - "string", - "null" - ] - }, - "plannedStartAt": { - "description": "Property plannedStartAt.", - "externalDocs": { - "url": "https://schema.org/DateTime" - }, - "type": [ - "string", - "null" - ] - } - } + ], + "description": "Generic organizational facilities (site/building/floor/zone/area)." }, - "InterventionTemplate.InstantiateInterventionTemplateOutput.jsonld": { + "LegacyFacility.FacilityOutput.jsonld-facility.read": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -55795,302 +69851,244 @@ { "type": "object", "properties": { - "interventionId": { - "description": "Property interventionId.", - "default": "", + "intervention": { + "description": "Property intervention.", + "type": [ + "string", + "null" + ] + }, + "recordStatus": { + "description": "Property recordStatus.", + "default": "published", "type": "string" }, - "number": { - "description": "Property number.", - "default": 0, + "revision": { + "description": "Property revision.", + "default": 1, "type": "integer" - } - } - } - ], - "description": "Resource InterventionTemplateResource.\n\nOrganization-scoped intervention templates: reusable blueprints (type,\npriority, defaults, planned items) instantiated into real intervention\ndrafts (without recurrence — see the module changelog for later lots)." - }, - "InterventionTemplate.InterventionTemplateOutput.jsonld": { - "allOf": [ - { - "$ref": "#/components/schemas/HydraItemBaseSchema" - }, - { - "type": "object", - "properties": { + }, "id": { + "readOnly": true, "description": "Property id.", "default": "", "type": "string" }, - "organization": { - "description": "Property organization.", - "default": "", - "type": "string" - }, - "name": { - "description": "Property name.", + "organizationId": { + "readOnly": true, + "description": "Property organizationId.", "default": "", "type": "string" }, - "description": { - "description": "Property description.", + "parentFacilityId": { + "readOnly": true, + "description": "Property parentFacilityId.", "type": [ "string", "null" ] }, + "hasChildren": { + "readOnly": true, + "description": "Property hasChildren.", + "default": false, + "type": "boolean" + }, + "equipmentCount": { + "readOnly": true, + "description": "Active equipment assigned to this facility", + "default": 0, + "type": "integer" + }, "type": { + "readOnly": true, "description": "Property type.", "default": "", "type": "string" }, - "priority": { - "description": "Property priority.", + "name": { + "readOnly": true, + "description": "Property name.", "default": "", "type": "string" }, - "defaultSite": { - "description": "Property defaultSite.", + "code": { + "readOnly": true, + "description": "Property code.", "type": [ "string", "null" ] }, - "defaultResponsible": { - "description": "Property defaultResponsible.", + "status": { + "readOnly": true, + "description": "Property status.", + "default": "", + "type": "string" + }, + "address": { + "readOnly": true, + "description": "Property address.", "type": [ "string", "null" ] }, - "duration": { - "description": "Property duration.", + "latitude": { + "readOnly": true, + "description": "Property latitude.", + "type": [ + "number", + "null" + ] + }, + "longitude": { + "readOnly": true, + "description": "Property longitude.", + "type": [ + "number", + "null" + ] + }, + "metadata": { + "readOnly": true, + "description": "Property metadata.", + "type": "object", + "additionalProperties": { + "type": [ + "string", + "null" + ] + } + }, + "levelIndex": { + "readOnly": true, + "description": "Optional stacking order of the floor (ground floor = 0, first basement = -1)", + "type": [ + "integer", + "null" + ] + }, + "elevationMeters": { + "readOnly": true, + "description": "Property elevationMeters", + "type": [ + "number", + "null" + ] + }, + "heightMeters": { + "readOnly": true, + "description": "Property heightMeters", + "type": [ + "number", + "null" + ] + }, + "customerId": { + "description": "Optional internal customer identifier on root sites", "type": [ "string", "null" ] }, - "labelIds": { - "description": "Property labelIds.", - "type": "array", - "items": { - "type": "string" + "planGeometry": { + "readOnly": true, + "description": "Optional spatial geometry bound to an ancestor floor plan (detail views only)", + "type": [ + "object", + "null" + ], + "additionalProperties": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "number" + } + } + }, + { + "type": "string" + } + ] } }, - "items": { - "description": "Property items.", + "geometryIssue": { + "readOnly": true, + "description": "Property geometryIssue.", + "type": [ + "string", + "null" + ], + "enum": [ + "invalid_geometry", + "plan_unavailable", + "outside_ancestry", + "other_plan", + null + ] + }, + "hierarchyIssues": { + "readOnly": true, + "description": "Property hierarchyIssues.", "type": "array", "items": { - "$ref": "#/components/schemas/InterventionTemplateItemOutput.jsonld" + "type": "string" } }, "createdAt": { + "readOnly": true, "description": "Property createdAt.", "default": "", "type": "string" }, "updatedAt": { + "readOnly": true, "description": "Property updatedAt.", "default": "", "type": "string" + }, + "path": { + "readOnly": true, + "description": "Ancestor breadcrumb, root first, excluding this facility; opt-in on collections through includePath.", + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "name", + "type" + ], + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "site", + "building", + "floor", + "zone", + "area" + ] + } + } + } } } } ], - "description": "Resource InterventionTemplateResource.\n\nOrganization-scoped intervention templates: reusable blueprints (type,\npriority, defaults, planned items) instantiated into real intervention\ndrafts (without recurrence — see the module changelog for later lots)." - }, - "InterventionTemplate.UpdateInterventionTemplateInput.jsonMergePatch": { - "type": "object", - "description": "Resource InterventionTemplateResource.\n\nOrganization-scoped intervention templates: reusable blueprints (type,\npriority, defaults, planned items) instantiated into real intervention\ndrafts (without recurrence — see the module changelog for later lots).", - "properties": { - "name": { - "minLength": 2, - "maxLength": 160, - "description": "Property name.", - "type": [ - "string", - "null" - ] - }, - "description": { - "maxLength": 5000, - "description": "Property description.", - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "site_setup", - "inventory", - "inspection_campaign" - ], - "description": "Property type.", - "type": [ - "string", - "null" - ] - }, - "priority": { - "enum": [ - "low", - "normal", - "high", - "urgent" - ], - "description": "Property priority.", - "type": [ - "string", - "null" - ] - }, - "defaultSite": { - "description": "Property defaultSite.", - "type": [ - "string", - "null" - ] - }, - "defaultResponsible": { - "description": "Property defaultResponsible.", - "type": [ - "string", - "null" - ] - }, - "duration": { - "description": "Property duration.", - "example": "P14D", - "type": [ - "string", - "null" - ] - }, - "labelIds": { - "description": "Property labelIds.", - "type": [ - "array", - "null" - ], - "items": { - "type": "string" - } - }, - "items": { - "description": "Property items.", - "type": "array", - "items": { - "$ref": "#/components/schemas/InterventionTemplateItemInput" - } - } - } - }, - "InterventionTemplateItemInput": { - "type": "object", - "properties": { - "estimatedMinutes": { - "minimum": 0, - "description": "Property estimatedMinutes.", - "type": [ - "integer", - "null" - ] - }, - "action": { - "maxLength": 60, - "description": "Property action.", - "default": "", - "example": "inspection", - "type": "string" - }, - "target": { - "description": "Property target.", - "type": [ - "string", - "null" - ] - }, - "resultResource": { - "description": "Property resultResource.", - "type": [ - "string", - "null" - ] - }, - "required": { - "description": "Property required.", - "default": true, - "type": "boolean" - }, - "defaultAssignee": { - "description": "Property defaultAssignee.", - "example": "/api/organizations/550e8400-e29b-41d4-a716-446655440000/members/018f0b68-6758-7a12-8a1d-3f0d97f63c13", - "type": [ - "string", - "null" - ] - } - }, - "required": [ - "action" - ] - }, - "InterventionTemplateItemOutput.jsonld": { - "type": "object", - "properties": { - "estimatedMinutes": { - "description": "Property estimatedMinutes.", - "type": [ - "integer", - "null" - ] - }, - "id": { - "description": "Property id.", - "default": "", - "type": "string" - }, - "position": { - "description": "Property position.", - "default": 0, - "type": "integer" - }, - "action": { - "description": "Property action.", - "default": "", - "type": "string" - }, - "target": { - "description": "Property target.", - "type": [ - "string", - "null" - ] - }, - "resultResource": { - "description": "Property resultResource.", - "type": [ - "string", - "null" - ] - }, - "required": { - "description": "Property required.", - "default": true, - "type": "boolean" - }, - "defaultAssignee": { - "description": "Property defaultAssignee.", - "type": [ - "string", - "null" - ] - } - } + "description": "Generic organizational facilities (site/building/floor/zone/area)." }, - "InterventionTime.TimeEntryOutput.jsonld": { + "LegacyFacility.FacilityPlanOverlayOutput.jsonld-facility.read": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -56098,20 +70096,99 @@ { "type": "object", "properties": { - "id": { - "description": "stable resource identifier, retained across idempotent retries", + "attachmentId": { + "readOnly": true, + "description": "Property attachmentId.", + "default": "", "type": "string" }, - "entry": { - "description": "independent time-entry aggregate or view", - "$ref": "#/components/schemas/TimeEntryView.jsonld" + "imageWidth": { + "readOnly": true, + "description": "Property imageWidth.", + "type": [ + "integer", + "null" + ] + }, + "imageHeight": { + "readOnly": true, + "description": "Property imageHeight.", + "type": [ + "integer", + "null" + ] + }, + "zones": { + "readOnly": true, + "description": "Property zones.", + "type": "array", + "items": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "number" + } + } + }, + { + "type": "string" + } + ] + } + } + }, + "equipment": { + "readOnly": true, + "description": "Property equipment.", + "type": "array", + "items": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string" + }, + { + "type": "null" + } + ] + } + } + }, + "geometryIssues": { + "readOnly": true, + "type": "array", + "items": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "equipmentIssues": { + "readOnly": true, + "type": "array", + "items": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } } } } ], - "description": "InterventionTimeResource." + "description": "Generic organizational facilities (site/building/floor/zone/area)." }, - "InterventionTime.TimeJournalOutput.jsonld": { + "LegacyFacility.GeocodeAddressOutput.jsonld-facility.read": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -56119,491 +70196,349 @@ { "type": "object", "properties": { - "workItemId": { - "description": "intervention task associated with this contribution", + "displayName": { + "readOnly": true, + "description": "Property displayName.", + "default": "", "type": "string" }, - "entries": { - "type": "array", - "items": { - "$ref": "#/components/schemas/TimeEntryView.jsonld" - } + "latitude": { + "readOnly": true, + "description": "Property latitude.", + "default": 0, + "type": "number" + }, + "longitude": { + "readOnly": true, + "description": "Property longitude.", + "default": 0, + "type": "number" } } } ], - "description": "InterventionTimeResource." + "description": "Generic organizational facilities (site/building/floor/zone/area)." }, - "InterventionTime.WriteTimeEntryInput": { + "LegacyFacility.MoveFacilityInput-facility.write": { "type": "object", - "description": "InterventionTimeResource.", + "description": "Generic organizational facilities (site/building/floor/zone/area).", + "required": [ + "parentFacilityId" + ], "properties": { - "id": { - "format": "uuid", - "externalDocs": { - "url": "https://schema.org/identifier" - }, - "type": [ - "string", - "null" - ] - }, - "memberId": { + "parentFacilityId": { "format": "uuid", + "description": "Target parent facility identifier. Field is required; use null to detach from parent.", "externalDocs": { "url": "https://schema.org/identifier" }, - "type": [ - "string", - "null" - ] - }, - "workedOn": { - "externalDocs": { - "url": "https://schema.org/Date" - }, - "default": "", - "type": "string" - }, - "minutes": { - "minimum": 1, - "maximum": 1440, - "default": 0, - "type": "integer" - }, - "note": { - "maxLength": 2000, + "example": "550e8400-e29b-41d4-a716-446655440001", "type": [ "string", "null" ] } - }, - "required": [ - "workedOn" - ] + } }, - "InterventionTime.WriteTimeEntryInput.jsonMergePatch": { + "LegacyFacility.SetFacilityPlanGeometryInput-facility.write": { "type": "object", - "description": "InterventionTimeResource.", + "description": "Generic organizational facilities (site/building/floor/zone/area).", + "required": [ + "attachmentId", + "points" + ], "properties": { - "id": { - "format": "uuid", - "externalDocs": { - "url": "https://schema.org/identifier" - }, - "type": [ - "string", - "null" - ] - }, - "memberId": { + "attachmentId": { "format": "uuid", + "description": "Floor plan attachment identifier the geometry is bound to. Required together with points; use null on both to clear.", "externalDocs": { "url": "https://schema.org/identifier" }, + "example": "550e8400-e29b-41d4-a716-446655440001", "type": [ "string", "null" ] }, - "workedOn": { - "externalDocs": { - "url": "https://schema.org/Date" - }, - "default": "", - "type": "string" - }, - "minutes": { - "minimum": 1, - "maximum": 1440, - "default": 0, - "type": "integer" - }, - "note": { - "maxLength": 2000, + "points": { + "minItems": 3, + "description": "Polygon points in normalized 0-1 image coordinates, at least 3. Required together with attachmentId; use null on both to clear.", + "example": [ + [ + 0.1, + 0.1 + ], + [ + 0.4, + 0.1 + ], + [ + 0.4, + 0.4 + ] + ], "type": [ - "string", + "array", "null" - ] + ], + "items": { + "type": "array", + "items": { + "type": "number" + } + } } } }, - "InterventionWorkItem.CreateInterventionWorkItemInput": { + "LegacyFacility.SuggestAddressesOutput.jsonld-facility.read": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { + "member": { + "description": "Property member.", + "type": "array", + "items": { + "$ref": "#/components/schemas/AddressSuggestionOutput.jsonld-facility.read" + } + }, + "totalItems": { + "description": "Property totalItems.", + "default": 0, + "type": "integer" + } + } + } + ], + "description": "Generic organizational facilities (site/building/floor/zone/area)." + }, + "LegacyFacility.UpdateFacilityInput-facility.write.jsonMergePatch": { "type": "object", - "description": "Resource InterventionWorkItemResource.", + "description": "Generic organizational facilities (site/building/floor/zone/area).", "properties": { - "workloadConfirmationToken": { - "maxLength": 64, - "description": "Property workloadConfirmationToken.", + "type": { + "enum": [ + "site", + "building", + "floor", + "zone", + "area" + ], + "description": "Facility type (partial update)", + "example": "building", "type": [ "string", "null" ] }, - "estimatedMinutes": { - "minimum": 0, - "description": "Property estimatedMinutes. Null means not estimated.", + "name": { + "minLength": 2, + "maxLength": 120, + "description": "Facility display name (partial update)", + "example": "Warehouse A", "type": [ - "integer", + "string", "null" ] }, - "workStartsOn": { - "description": "Property workStartsOn. Organization-local date.", - "externalDocs": { - "url": "https://schema.org/Date" - }, + "code": { + "maxLength": 80, + "description": "Optional facility code", + "example": "BLD-WHS-A", "type": [ "string", "null" ] }, - "workEndsOn": { - "description": "Property workEndsOn. Organization-local date.", - "externalDocs": { - "url": "https://schema.org/Date" - }, + "address": { + "maxLength": 255, + "description": "Optional address", + "example": "12 avenue Victor Hugo, Lyon", "type": [ "string", "null" ] }, - "intervention": { - "description": "Property intervention.", - "default": "", - "type": "string" - }, - "action": { - "enum": [ - "site_setup", - "inventory", - "inspection" - ], - "description": "Property action.", - "default": "inventory", - "type": "string" - }, - "target": { - "description": "Property target.", + "latitude": { + "minimum": -90, + "maximum": 90, + "description": "Optional latitude, required together with longitude (partial update)", + "example": 45.764, "type": [ - "string", + "number", "null" ] }, - "resultResource": { - "description": "Property resultResource.", + "longitude": { + "minimum": -180, + "maximum": 180, + "description": "Optional longitude, required together with latitude (partial update)", + "example": 4.8357, "type": [ - "string", + "number", "null" ] }, - "assignee": { - "description": "Property assignee.", + "metadata": { + "description": "Optional free-form metadata", + "example": { + "surfaceM2": 4500 + }, + "type": "object", + "additionalProperties": { + "type": [ + "string", + "null" + ] + } + }, + "levelIndex": { + "minimum": -100, + "maximum": 200, + "description": "Optional stacking order of the floor (partial update)", + "example": -1, "type": [ - "string", + "integer", "null" ] }, - "source": { - "enum": [ - "planned", - "discovered" - ], - "description": "Property source.", - "default": "planned", - "type": "string" - }, - "required": { - "description": "Property required.", - "default": true, - "type": "boolean" - } - }, - "required": [ - "intervention" - ] - }, - "InterventionWorkItem.InterventionWorkItemOutput.jsonld": { - "allOf": [ - { - "$ref": "#/components/schemas/HydraItemBaseSchema" - }, - { - "type": "object", - "properties": { - "spentMinutes": { - "description": "Property spentMinutes.", - "default": 0, - "type": "integer" - }, - "allowedActions": { - "description": "Property allowedActions.", - "default": { - "canLogTime": false, - "canManageTime": false, - "canReestimate": false, - "canReassign": false, - "canEditPlanning": false, - "canExecute": false - }, - "type": "object", - "additionalProperties": { - "type": "boolean" - } - }, - "estimatedMinutes": { - "description": "Property estimatedMinutes.", - "type": [ - "integer", - "null" - ] - }, - "remainingMinutes": { - "description": "Property remainingMinutes.", - "type": [ - "integer", - "null" - ] - }, - "workStartsOn": { - "description": "Property workStartsOn.", - "type": [ - "string", - "null" - ] - }, - "workEndsOn": { - "description": "Property workEndsOn.", - "type": [ - "string", - "null" - ] - }, - "id": { - "description": "Property id.", - "default": "", - "type": "string" - }, - "intervention": { - "description": "Property intervention.", - "default": "", - "type": "string" - }, - "action": { - "description": "Property action.", - "default": "", - "type": "string" - }, - "target": { - "description": "Property target.", - "type": [ - "string", - "null" - ] - }, - "targetSummary": { - "description": "Property targetSummary.", - "anyOf": [ - { - "$ref": "#/components/schemas/InterventionTargetOutput.jsonld" - }, - { - "type": "null" - } - ] - }, - "resultResource": { - "description": "Property resultResource.", - "type": [ - "string", - "null" - ] - }, - "assignee": { - "description": "Property assignee.", - "type": [ - "string", - "null" - ] - }, - "assigneeProfile": { - "description": "Property assigneeProfile.", - "anyOf": [ - { - "$ref": "#/components/schemas/InterventionAssigneeOutput.jsonld" - }, - { - "type": "null" - } - ] - }, - "source": { - "description": "Property source.", - "default": "planned", - "type": "string" - }, - "status": { - "description": "Property status.", - "default": "planned", - "type": "string" - }, - "required": { - "description": "Property required.", - "default": true, - "type": "boolean" - }, - "skipReason": { - "description": "Property skipReason.", - "type": [ - "string", - "null" - ] - }, - "evidenceCount": { - "description": "Property evidenceCount.", - "default": 0, - "type": "integer" - }, - "revision": { - "description": "Property revision.", - "default": 1, - "type": "integer" - }, - "createdAt": { - "description": "Property createdAt.", - "default": "", - "type": "string" - }, - "updatedAt": { - "description": "Property updatedAt.", - "default": "", - "type": "string" - } - } - } - ], - "description": "Resource InterventionWorkItemResource." - }, - "InterventionWorkItem.UpdateInterventionWorkItemInput.jsonMergePatch": { - "type": "object", - "description": "Resource InterventionWorkItemResource.", - "properties": { - "workloadConfirmationToken": { - "maxLength": 64, - "description": "Property workloadConfirmationToken.", + "elevationMeters": { + "minimum": -10000, + "maximum": 10000, + "description": "Optional floor elevation in meters", + "example": -3, "type": [ - "string", + "number", "null" ] }, - "estimatedMinutes": { + "heightMeters": { "minimum": 0, - "description": "Property estimatedMinutes. Null means not estimated.", + "exclusiveMinimum": 0, + "maximum": 1000, + "description": "Optional floor height in meters", + "example": 3, "type": [ - "integer", + "number", "null" ] }, - "workStartsOn": { - "description": "Property workStartsOn. Organization-local date.", + "customerId": { + "format": "uuid", + "description": "Internal customer identifier for a root site", "externalDocs": { - "url": "https://schema.org/Date" + "url": "https://schema.org/identifier" }, "type": [ "string", "null" ] - }, - "workEndsOn": { - "description": "Property workEndsOn. Organization-local date.", + } + } + }, + "LegacyInspection.CreateInspectionInput-inspection.write": { + "type": "object", + "description": "Inspection records for fire safety equipment.", + "properties": { + "clientId": { + "format": "uuid", + "description": "Client-generated UUID for idempotent offline creation", "externalDocs": { - "url": "https://schema.org/Date" + "url": "https://schema.org/identifier" }, "type": [ "string", "null" ] }, - "remainingMinutes": { - "minimum": 0, - "description": "Property remainingMinutes. Explicit re-estimation only.", - "type": [ - "integer", - "null" - ] - }, - "resultResource": { - "description": "Property resultResource.", + "organization": { + "pattern": "^(/api/organizations/[0-9a-fA-F-]{36})$", + "description": "Organization IRI, required on the canonical endpoint", "type": [ "string", "null" ] }, - "assignee": { - "description": "Property assignee.", + "intervention": { + "pattern": "^(/api/interventions/[0-9a-fA-F-]{36})$", + "description": "Optional field intervention IRI", "type": [ "string", "null" ] }, - "status": { + "equipmentId": { + "format": "uuid", + "description": "Equipment identifier", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "default": "", + "example": "550e8400-e29b-41d4-a716-446655440000", + "type": "string" + }, + "result": { "enum": [ - "planned", - "in_progress", - "completed", - "skipped" + "pass", + "fail", + "partial" ], - "description": "Property status.", - "type": [ - "string", - "null" - ] + "description": "Inspection result", + "default": "", + "example": "pass", + "type": "string" }, - "skipReason": { - "maxLength": 2000, - "description": "Property skipReason.", + "performedAt": { + "description": "Date the inspection was performed (ISO 8601)", + "default": "", + "example": "2024-06-15T10:00:00+02:00", + "type": "string" + }, + "inspectorType": { + "enum": [ + "user", + "external" + ], + "description": "Inspector type (user or external)", + "default": "", + "example": "user", + "type": "string" + }, + "inspectorName": { + "maxLength": 255, + "description": "Inspector display name", + "default": "", + "example": "Jean Dupont", + "type": "string" + }, + "facilityId": { + "format": "uuid", + "description": "Optional facility identifier", + "externalDocs": { + "url": "https://schema.org/identifier" + }, "type": [ "string", "null" ] - } - } - }, - "LegacyEquipment.AssignToFacilityInput-equipment.write": { - "type": "object", - "description": "Fire safety equipment and assets managed at organization level.", - "required": [ - "facilityId" - ], - "properties": { - "facilityId": { + }, + "checklistId": { "format": "uuid", - "description": "Target facility identifier", + "description": "Optional checklist identifier", "externalDocs": { "url": "https://schema.org/identifier" }, - "default": "", - "example": "550e8400-e29b-41d4-a716-446655440001", - "type": "string" - }, - "installedAt": { - "description": "Optional installation date (ISO 8601)", - "example": "2024-01-15T09:00:00+00:00", "type": [ "string", "null" ] - } - } - }, - "LegacyEquipment.CreateEquipmentInput-equipment.write": { - "type": "object", - "description": "Fire safety equipment and assets managed at organization level.", - "properties": { - "clientId": { + }, + "inspectorUserId": { "format": "uuid", - "description": "Client-generated UUID for idempotent offline creation", + "description": "Optional user identifier (required for user type)", "externalDocs": { "url": "https://schema.org/identifier" }, @@ -56612,32 +70547,45 @@ "null" ] }, - "organization": { - "pattern": "^(/api/organizations/[0-9a-fA-F-]{36})$", - "description": "Organization IRI, required on the canonical endpoint", + "inspectorOrganizationName": { + "maxLength": 255, + "description": "Optional external organization name", "type": [ "string", "null" ] }, - "intervention": { - "pattern": "^(/api/interventions/[0-9a-fA-F-]{36})$", - "description": "Optional field intervention IRI", + "notes": { + "maxLength": 5000, + "description": "Optional free-form notes", "type": [ "string", "null" ] }, - "facility": { - "pattern": "^(/api/facilities/[0-9a-fA-F-]{36})$", - "description": "Optional facility IRI", + "signature": { + "description": "Optional signature data", "type": [ "string", "null" ] - }, - "onboardingSessionId": { + } + }, + "required": [ + "equipmentId", + "result", + "performedAt", + "inspectorType", + "inspectorName" + ] + }, + "LegacyInspection.EditInspectionInput-inspection.write.jsonMergePatch": { + "type": "object", + "description": "Inspection records for fire safety equipment.", + "properties": { + "equipmentId": { "format": "uuid", + "description": "Equipment identifier", "externalDocs": { "url": "https://schema.org/identifier" }, @@ -56646,83 +70594,67 @@ "null" ] }, - "onboardingItemKey": { + "facilityId": { + "format": "uuid", + "description": "Optional facility identifier", + "externalDocs": { + "url": "https://schema.org/identifier" + }, "type": [ "string", "null" ] }, - "type": { - "enum": [ - "fire_extinguisher", - "smoke_detector", - "heat_detector", - "sprinkler", - "fire_alarm_panel", - "hydrant", - "fire_door", - "emergency_lighting", - "access_control", - "camera", - "gas_detector", - "other" - ], - "description": "Equipment type", - "default": "", - "example": "fire_extinguisher", - "type": "string" - }, - "subType": { - "maxLength": 100, - "description": "Optional free-form sub-type", - "example": "CO2 6kg", + "checklistId": { + "format": "uuid", + "description": "Optional checklist identifier", + "externalDocs": { + "url": "https://schema.org/identifier" + }, "type": [ "string", "null" ] }, - "brand": { - "maxLength": 100, - "description": "Optional brand name", - "example": "Sicli", + "result": { + "enum": [ + "pass", + "fail", + "partial" + ], + "description": "Inspection result", + "example": "pass", "type": [ "string", "null" ] }, - "model": { - "maxLength": 100, - "description": "Optional model name", - "example": "Pro 6", + "performedAt": { + "description": "Date the inspection was performed (ISO 8601)", + "example": "2024-06-15T10:00:00+02:00", "type": [ "string", "null" ] }, - "serialNumber": { - "maxLength": 100, - "description": "Optional serial number (unique per organization)", - "example": "SN-2024-001", + "notes": { + "maxLength": 5000, + "description": "Optional free-form notes", "type": [ "string", "null" ] }, - "locationLabel": { - "maxLength": 255, - "description": "Optional free-form location label", - "example": "Near staircase B", + "signature": { + "description": "Optional signature data", "type": [ "string", "null" ] } - }, - "required": [ - "type" - ] + } }, - "LegacyEquipment.EquipmentOutput.jsonld-equipment.read": { + "LegacyInspection.InspectionOutput.jsonld-inspection.read": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -56759,67 +70691,49 @@ "default": "", "type": "string" }, - "facilityId": { - "readOnly": true, - "description": "Property facilityId.", - "type": [ - "string", - "null" - ] - }, - "type": { + "equipmentId": { "readOnly": true, - "description": "Property type.", + "description": "Property equipmentId.", "default": "", "type": "string" }, - "subType": { - "readOnly": true, - "description": "Property subType.", - "type": [ - "string", - "null" - ] - }, - "brand": { + "facilityId": { "readOnly": true, - "description": "Property brand.", + "description": "Property facilityId.", "type": [ "string", "null" ] }, - "model": { + "equipmentSerialNumber": { "readOnly": true, - "description": "Property model.", + "description": "Serial number of the inspected equipment", "type": [ "string", "null" ] }, - "serialNumber": { + "facilityName": { "readOnly": true, - "description": "Property serialNumber.", + "description": "Display name of the inspected facility", "type": [ "string", "null" ] }, - "locationLabel": { + "checklistName": { "readOnly": true, - "description": "Property locationLabel.", + "description": "Display name of the followed checklist", "type": [ "string", "null" ] }, - "facilityName": { + "result": { "readOnly": true, - "description": "Display name of the assigned facility", - "type": [ - "string", - "null" - ] + "description": "Property result.", + "default": "", + "type": "string" }, "status": { "readOnly": true, @@ -56827,53 +70741,53 @@ "default": "", "type": "string" }, - "installedAt": { + "performedAt": { "readOnly": true, - "description": "Property installedAt.", + "description": "Property performedAt.", + "default": "", + "type": "string" + }, + "inspector": { + "readOnly": true, + "description": "Property inspector.", + "anyOf": [ + { + "$ref": "#/components/schemas/InspectorOutput.jsonld-inspection.read" + }, + { + "type": "null" + } + ] + }, + "checklistId": { + "readOnly": true, + "description": "Property checklistId.", "type": [ "string", "null" ] }, - "commissionedAt": { + "notes": { "readOnly": true, - "description": "Property commissionedAt.", + "description": "Property notes.", "type": [ "string", "null" ] }, - "tags": { - "readOnly": true, - "description": "Property tags.", - "type": "array", - "items": { - "$ref": "#/components/schemas/TagOutput.jsonld-equipment.read" - } - }, - "maintenanceDueStatus": { - "readOnly": true, - "description": "Property maintenanceDueStatus.", - "default": "unscheduled", - "type": "string" - }, - "planPosition": { + "signature": { "readOnly": true, - "description": "Position pinned on a floor plan attachment (detail read only)", + "description": "Property signature.", "type": [ - "object", + "string", "null" - ], - "additionalProperties": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string" - } - ] - } + ] + }, + "nonConformitiesCount": { + "readOnly": true, + "description": "Property nonConformitiesCount.", + "default": 0, + "type": "integer" }, "createdAt": { "readOnly": true, @@ -56890,312 +70804,105 @@ } } ], - "description": "Fire safety equipment and assets managed at organization level." + "description": "Inspection records for fire safety equipment." }, - "LegacyEquipment.SetEquipmentPlanPositionInput-equipment.write": { + "MaintenanceCampaign.GenerateInspectionCampaignInput": { "type": "object", - "description": "Fire safety equipment and assets managed at organization level.", + "description": "Resource MaintenanceCampaignResource.\n\nGenerates an intervention draft (\"inspection campaign\") from an\norganization's due/overdue maintenance schedules.", "required": [ - "attachmentId", - "x", - "y" + "organization", + "dueBefore" ], "properties": { - "attachmentId": { - "format": "uuid", - "description": "Floor plan attachment identifier the position is bound to. Required together with x/y; use null on all three to clear.", - "externalDocs": { - "url": "https://schema.org/identifier" - }, - "example": "550e8400-e29b-41d4-a716-446655440001", - "type": [ - "string", - "null" - ] - }, - "x": { - "minimum": 0, - "maximum": 1, - "description": "Normalized x coordinate in [0, 1]. Required together with attachmentId/y; use null on all three to clear.", - "example": 0.42, - "type": [ - "number", - "null" - ] - }, - "y": { - "minimum": 0, - "maximum": 1, - "description": "Normalized y coordinate in [0, 1]. Required together with attachmentId/x; use null on all three to clear.", - "example": 0.17, - "type": [ - "number", - "null" - ] - } - } - }, - "LegacyEquipment.UpdateEquipmentInput-equipment.write.jsonMergePatch": { - "type": "object", - "description": "Fire safety equipment and assets managed at organization level.", - "properties": { - "type": { - "enum": [ - "fire_extinguisher", - "smoke_detector", - "heat_detector", - "sprinkler", - "fire_alarm_panel", - "hydrant", - "fire_door", - "emergency_lighting", - "access_control", - "camera", - "gas_detector", - "other" - ], - "description": "Equipment type", - "default": "", - "example": "fire_extinguisher", - "type": "string" - }, - "subType": { - "maxLength": 100, - "description": "Optional free-form sub-type", - "example": "CO2 6kg", - "type": [ - "string", - "null" - ] - }, - "brand": { - "maxLength": 100, - "description": "Optional brand name", - "example": "Sicli", - "type": [ - "string", - "null" - ] - }, - "model": { - "maxLength": 100, - "description": "Optional model name", - "example": "Pro 6", - "type": [ - "string", - "null" - ] - }, - "serialNumber": { - "maxLength": 100, - "description": "Optional serial number (unique per organization)", - "example": "SN-2024-001", - "type": [ - "string", - "null" - ] - }, - "locationLabel": { - "maxLength": 255, - "description": "Optional free-form location label", - "example": "Near staircase B", - "type": [ - "string", - "null" - ] - } - } - }, - "LegacyFacility.CreateFacilityInput-facility.write": { - "type": "object", - "description": "Generic organizational facilities (site/building/floor/zone/area).", - "properties": { - "clientId": { - "format": "uuid", - "description": "Client-generated UUID for idempotent offline creation", - "externalDocs": { - "url": "https://schema.org/identifier" - }, - "type": [ - "string", - "null" - ] - }, "organization": { - "pattern": "^(/api/organizations/[0-9a-fA-F-]{36})$", - "description": "Organization IRI, required on the canonical endpoint", - "type": [ - "string", - "null" - ] - }, - "intervention": { - "pattern": "^(/api/interventions/[0-9a-fA-F-]{36})$", - "description": "Optional field intervention IRI", - "type": [ - "string", - "null" - ] - }, - "onboardingSessionId": { - "format": "uuid", - "externalDocs": { - "url": "https://schema.org/identifier" - }, - "type": [ - "string", - "null" - ] - }, - "onboardingItemKey": { - "type": [ - "string", - "null" - ] - }, - "type": { - "enum": [ - "site", - "building", - "floor", - "zone", - "area" - ], - "description": "Facility type", + "description": "Property organization.", "default": "", - "example": "site", "type": "string" }, "name": { "minLength": 2, - "maxLength": 120, - "description": "Facility display name", + "maxLength": 160, + "description": "Property name.", "default": "", - "example": "Headquarters", "type": "string" }, - "parentFacilityId": { - "format": "uuid", - "description": "Optional parent facility identifier", - "externalDocs": { - "url": "https://schema.org/identifier" - }, - "example": "550e8400-e29b-41d4-a716-446655440001", - "type": [ - "string", - "null" - ] - }, - "code": { - "maxLength": 80, - "description": "Optional facility code", - "example": "SITE-PAR-001", + "facility": { + "description": "Property facility.", "type": [ "string", "null" ] }, - "address": { - "maxLength": 255, - "description": "Optional address", - "example": "10 rue de Rivoli, Paris", + "equipmentType": { + "description": "Property equipmentType.", "type": [ "string", "null" ] }, - "latitude": { - "minimum": -90, - "maximum": 90, - "description": "Optional latitude, required together with longitude", - "example": 48.8566, - "type": [ - "number", - "null" - ] - }, - "longitude": { - "minimum": -180, - "maximum": 180, - "description": "Optional longitude, required together with latitude", - "example": 2.3522, - "type": [ - "number", - "null" - ] + "dueBefore": { + "description": "Property dueBefore.", + "default": "", + "type": "string" + } + } + }, + "MaintenanceCampaign.GenerateInspectionCampaignOutput.jsonld": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" }, - "metadata": { - "description": "Optional free-form metadata", - "example": { - "country": "FR", - "timezone": "Europe/Paris" - }, + { "type": "object", - "additionalProperties": { - "type": [ - "string", - "null" - ] + "properties": { + "interventionId": { + "description": "Property interventionId.", + "default": "", + "type": "string" + }, + "number": { + "description": "Property number.", + "default": 0, + "type": "integer" + }, + "workItemsCount": { + "description": "Property workItemsCount.", + "default": 0, + "type": "integer" + } } - }, - "levelIndex": { - "minimum": -100, - "maximum": 200, - "description": "Optional stacking order of the floor (ground floor = 0, first basement = -1)", - "example": 0, - "type": [ - "integer", - "null" - ] - }, - "elevationMeters": { - "minimum": -10000, - "maximum": 10000, - "description": "Optional floor elevation in meters", - "example": -3, - "type": [ - "number", - "null" - ] - }, - "heightMeters": { - "minimum": 0, - "exclusiveMinimum": 0, - "maximum": 1000, - "description": "Optional floor height in meters", - "example": 3, - "type": [ - "number", - "null" - ] } - }, - "required": [ - "type", - "name" - ] + ], + "description": "Resource MaintenanceCampaignResource.\n\nGenerates an intervention draft (\"inspection campaign\") from an\norganization's due/overdue maintenance schedules." }, - "LegacyFacility.DuplicateFacilitySubtreeInput-facility.write": { + "MaintenanceCost.CreateMaintenanceExpenseInput-maintenance_cost.write": { "type": "object", - "description": "Generic organizational facilities (site/building/floor/zone/area).", + "description": "Class MaintenanceCostResource. Financial permissions are enforced by application use cases. @category Resource", + "additionalProperties": false, "properties": { - "name": { - "minLength": 2, - "maxLength": 120, - "description": "Name for the copy's root. Defaults to \"{original} (copy)\" when omitted.", - "example": "Site A (copy)", + "clientId": { + "default": "", + "type": "string" + }, + "amount": { + "default": "", + "type": "string" + }, + "description": { + "default": "", + "type": "string" + }, + "incurredAt": { + "default": "", + "type": "string" + }, + "workItemId": { "type": [ "string", "null" ] }, - "parentFacilityId": { - "format": "uuid", - "description": "Target parent facility identifier for the copy's root. Defaults to the source facility's own parent when omitted.", - "externalDocs": { - "url": "https://schema.org/identifier" - }, - "example": "550e8400-e29b-41d4-a716-446655440001", + "adjustmentOf": { "type": [ "string", "null" @@ -57203,7 +70910,7 @@ } } }, - "LegacyFacility.FacilityBuildingModelOutput.jsonld-facility.read": { + "MaintenanceCost.MaintenanceCostOutput.jsonld-maintenance_cost.read": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -57211,370 +70918,130 @@ { "type": "object", "properties": { - "buildingId": { - "readOnly": true, - "description": "Property buildingId.", + "id": { "default": "", "type": "string" }, - "buildingName": { - "readOnly": true, - "description": "Property buildingName.", + "interventionId": { "default": "", "type": "string" }, - "floors": { - "readOnly": true, - "description": "Property floors.", - "type": "array", - "items": { - "type": "object", - "required": [ - "facilityId", - "name", - "levelIndex", - "elevationMeters", - "heightMeters", - "status", - "hierarchyIssues", - "plan", - "outline", - "rooms", - "equipment", - "diagnostics" - ], - "properties": { - "facilityId": { - "type": "string", - "format": "uuid" - }, - "name": { - "type": "string" - }, - "levelIndex": { - "type": [ - "integer", - "null" - ] - }, - "elevationMeters": { - "type": [ - "number", - "null" - ] - }, - "heightMeters": { - "type": [ - "number", - "null" - ] - }, - "status": { - "type": "string" - }, - "hierarchyIssues": { - "type": "array", - "items": { - "type": "string", - "enum": [ - "missing_parent", - "invalid_parent_type", - "unpublished_parent", - "invalid_ancestor", - "cycle", - "depth_exceeded" - ] - } - }, - "plan": { - "type": [ - "object", - "null" - ], - "required": [ - "attachmentId", - "imageWidth", - "imageHeight", - "calibration", - "calibrationBuildingId", - "calibrationIssue" - ], - "properties": { - "attachmentId": { - "type": "string", - "format": "uuid" - }, - "imageWidth": { - "type": [ - "integer", - "null" - ] - }, - "imageHeight": { - "type": [ - "integer", - "null" - ] - }, - "calibrationBuildingId": { - "type": [ - "string", - "null" - ], - "format": "uuid" - }, - "calibrationIssue": { - "type": [ - "string", - "null" - ], - "enum": [ - "building_changed", - "unverified_frame", - null - ] - }, - "calibration": { - "type": [ - "object", - "null" - ], - "required": [ - "widthMeters", - "rotationDegrees", - "offsetXMeters", - "offsetZMeters" - ], - "properties": { - "widthMeters": { - "type": "number" - }, - "rotationDegrees": { - "type": "number" - }, - "offsetXMeters": { - "type": "number" - }, - "offsetZMeters": { - "type": "number" - } - } - } - } - }, - "outline": { - "type": [ - "object", - "null" - ], - "required": [ - "source", - "points" - ], - "properties": { - "source": { - "type": "string", - "enum": [ - "plan_geometry", - "rooms_bbox", - "image_rect" - ] - }, - "points": { - "type": "array", - "minItems": 3, - "items": { - "type": "array", - "minItems": 2, - "maxItems": 2, - "items": { - "type": "number", - "minimum": 0, - "maximum": 1 - } - } - } - } - }, - "rooms": { - "type": "array", - "items": { - "type": "object", - "required": [ - "facilityId", - "name", - "type", - "status", - "points" - ], - "properties": { - "facilityId": { - "type": "string", - "format": "uuid" - }, - "name": { - "type": "string" - }, - "type": { - "type": "string" - }, - "status": { - "type": "string" - }, - "points": { - "type": "array", - "minItems": 3, - "items": { - "type": "array", - "minItems": 2, - "maxItems": 2, - "items": { - "type": "number", - "minimum": 0, - "maximum": 1 - } - } - } - } - } - }, - "equipment": { - "type": "array", - "items": { - "type": "object", - "required": [ - "equipmentId", - "facilityId", - "type", - "serialNumber", - "locationLabel", - "status", - "position", - "placementIssue" - ], - "properties": { - "equipmentId": { - "type": "string", - "format": "uuid" - }, - "facilityId": { - "type": "string", - "format": "uuid" - }, - "type": { - "type": "string" - }, - "serialNumber": { - "type": [ - "string", - "null" - ] - }, - "locationLabel": { - "type": [ - "string", - "null" - ] - }, - "status": { - "type": "string" - }, - "position": { - "type": [ - "object", - "null" - ], - "required": [ - "attachmentId", - "x", - "y" - ], - "properties": { - "attachmentId": { - "type": "string", - "format": "uuid" - }, - "x": { - "type": "number", - "minimum": 0, - "maximum": 1 - }, - "y": { - "type": "number", - "minimum": 0, - "maximum": 1 - } - } - }, - "placementIssue": { - "type": [ - "string", - "null" - ], - "enum": [ - "missing_plan", - "unplaced", - "other_plan", - "invalid_position", - "outside_ancestry", - null - ] - } - } - } - }, - "diagnostics": { - "type": "object", - "required": [ - "invalidGeometryCount", - "unpositionedEquipmentCount", - "geometryIssues" - ], - "properties": { - "invalidGeometryCount": { - "type": "integer", - "minimum": 0 - }, - "unpositionedEquipmentCount": { - "type": "integer", - "minimum": 0 - }, - "geometryIssues": { - "type": "array", - "items": { - "type": "object", - "required": [ - "facilityId", - "code" - ], - "properties": { - "facilityId": { - "type": "string", - "format": "uuid" - }, - "code": { - "type": "string", - "enum": [ - "invalid_geometry", - "plan_unavailable", - "outside_ancestry", - "other_plan" - ] - } - } - } - } + "organizationId": { + "default": "", + "type": "string" + }, + "currency": { + "default": "", + "type": "string" + }, + "plannedBudget": { + "type": [ + "string", + "null" + ] + }, + "estimatedMinutes": { + "type": [ + "integer", + "null" + ] + }, + "planningRevision": { + "default": 0, + "type": "integer" + }, + "planningEditable": { + "default": false, + "type": "boolean" + }, + "resources": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + }, + { + "type": "null" } - } + ] } } + }, + "current": { + "type": "object", + "additionalProperties": { + "type": [ + "string", + "null" + ] + } + }, + "frozen": { + "type": [ + "object", + "null" + ], + "additionalProperties": { + "type": [ + "string", + "null" + ] + } } } } ], - "description": "Generic organizational facilities (site/building/floor/zone/area)." + "description": "Class MaintenanceCostResource. Financial permissions are enforced by application use cases. @category Resource" }, - "LegacyFacility.FacilityOutput.jsonld-facility.read": { + "MaintenanceCost.WriteCostPlanningInput-maintenance_cost.write.jsonMergePatch": { + "type": "object", + "description": "Class MaintenanceCostResource. Financial permissions are enforced by application use cases. @category Resource", + "additionalProperties": false, + "properties": { + "plannedBudget": { + "type": [ + "string", + "null" + ] + }, + "estimatedMinutes": { + "type": [ + "integer", + "null" + ] + }, + "resources": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": { + "type": [ + "string", + "null" + ] + } + } + } + } + }, + "MaintenanceCostCurrency.ConfigureMaintenanceCurrencyInput-maintenance_cost_currency.write.jsonMergePatch": { + "type": "object", + "description": "Private currency settings shared by all internal maintenance finance.", + "additionalProperties": false, + "properties": { + "currency": { + "default": "", + "type": "string" + } + } + }, + "MaintenanceCostCurrency.MaintenanceCurrencyOutput.jsonld-maintenance_cost_currency.read": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -57582,108 +71049,260 @@ { "type": "object", "properties": { - "intervention": { - "description": "Property intervention.", - "type": [ - "string", - "null" - ] - }, - "recordStatus": { - "description": "Property recordStatus.", - "default": "published", + "organizationId": { + "default": "", "type": "string" }, - "revision": { - "description": "Property revision.", - "default": 1, - "type": "integer" + "currency": { + "default": "EUR", + "type": "string" }, + "locked": { + "default": false, + "type": "boolean" + } + } + } + ], + "description": "Private currency settings shared by all internal maintenance finance." + }, + "MaintenanceCostRate.CreateMaintenanceRateInput-maintenance_cost_rate.write": { + "type": "object", + "description": "Dedicated financial permissions protect append-only historical rate parameters.", + "additionalProperties": false, + "required": [ + "clientId", + "memberId", + "hourlyAmount", + "effectiveFrom" + ], + "properties": { + "clientId": { + "format": "uuid", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "default": "", + "type": "string" + }, + "memberId": { + "format": "uuid", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "default": "", + "type": "string" + }, + "hourlyAmount": { + "default": "", + "type": "string" + }, + "effectiveFrom": { + "externalDocs": { + "url": "https://schema.org/Date" + }, + "default": "", + "type": "string" + } + } + }, + "MaintenanceCostRate.MaintenanceRateOutput.jsonld-maintenance_cost_rate.read": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { "id": { - "readOnly": true, - "description": "Property id.", "default": "", "type": "string" }, - "organizationId": { - "readOnly": true, - "description": "Property organizationId.", + "memberId": { "default": "", "type": "string" }, - "parentFacilityId": { - "readOnly": true, - "description": "Property parentFacilityId.", - "type": [ - "string", - "null" - ] + "hourlyAmount": { + "default": "", + "type": "string" }, - "hasChildren": { - "readOnly": true, - "description": "Property hasChildren.", + "currency": { + "default": "", + "type": "string" + }, + "effectiveFrom": { + "default": "", + "type": "string" + }, + "replayed": { "default": false, "type": "boolean" + } + } + } + ], + "description": "Dedicated financial permissions protect append-only historical rate parameters." + }, + "MaintenanceEconomic.MaintenanceEconomicDossierOutput.jsonld-maintenance_economic.read": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { + "id": { + "default": "", + "type": "string" }, - "equipmentCount": { - "readOnly": true, - "description": "Active equipment assigned to this facility", + "number": { "default": 0, "type": "integer" }, - "type": { - "readOnly": true, - "description": "Property type.", + "name": { "default": "", "type": "string" }, - "name": { - "readOnly": true, - "description": "Property name.", + "type": { "default": "", "type": "string" }, - "code": { - "readOnly": true, - "description": "Property code.", - "type": [ - "string", - "null" - ] - }, "status": { - "readOnly": true, - "description": "Property status.", "default": "", "type": "string" }, - "address": { - "readOnly": true, - "description": "Property address.", + "publishedAt": { "type": [ "string", "null" ] }, - "latitude": { - "readOnly": true, - "description": "Property latitude.", + "site": { "type": [ - "number", + "object", "null" - ] + ], + "additionalProperties": { + "type": "string" + } }, - "longitude": { - "readOnly": true, - "description": "Property longitude.", + "customer": { "type": [ - "number", + "object", "null" - ] + ], + "additionalProperties": { + "type": "string" + } }, - "metadata": { - "readOnly": true, - "description": "Property metadata.", + "snapshotState": { + "default": "", + "type": "string" + }, + "identityComplete": { + "default": false, + "type": "boolean" + }, + "equipment": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + { + "type": "string" + }, + { + "type": "null" + } + ] + } + } + } + } + } + ], + "description": "Class MaintenanceEconomicResource\n\nDedicated financial endpoints exclude ordinary operational and client reports." + }, + "MaintenanceEconomic.MaintenanceEconomicReportOutput.jsonld-maintenance_economic.read": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { + "id": { + "default": "", + "type": "string" + }, + "organizationId": { + "default": "", + "type": "string" + }, + "from": { + "default": "", + "type": "string" + }, + "to": { + "default": "", + "type": "string" + }, + "groupBy": { + "default": "", + "type": "string" + }, + "currency": { + "default": "", + "type": "string" + }, + "page": { + "default": 1, + "type": "integer" + }, + "itemsPerPage": { + "default": 30, + "type": "integer" + }, + "totalItems": { + "default": 0, + "type": "integer" + }, + "interventionCount": { + "default": 0, + "type": "integer" + }, + "publishedInterventionCount": { + "default": 0, + "type": "integer" + }, + "liveInterventionCount": { + "default": 0, + "type": "integer" + }, + "missingSnapshotCount": { + "default": 0, + "type": "integer" + }, + "rows": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": { + "type": [ + "string", + "null" + ] + } + } + }, + "unallocated": { "type": "object", "additionalProperties": { "type": [ @@ -57692,127 +71311,170 @@ ] } }, - "levelIndex": { - "readOnly": true, - "description": "Optional stacking order of the floor (ground floor = 0, first basement = -1)", - "type": [ - "integer", - "null" - ] + "current": { + "type": "object", + "additionalProperties": { + "type": [ + "string", + "null" + ] + } }, - "elevationMeters": { - "readOnly": true, - "description": "Property elevationMeters", - "type": [ - "number", - "null" - ] + "frozen": { + "type": "object", + "additionalProperties": { + "type": [ + "string", + "null" + ] + } }, - "heightMeters": { - "readOnly": true, - "description": "Property heightMeters", - "type": [ - "number", - "null" - ] + "planned": { + "type": "object", + "additionalProperties": { + "type": [ + "string", + "null" + ] + } }, - "planGeometry": { - "readOnly": true, - "description": "Optional spatial geometry bound to an ancestor floor plan (detail views only)", - "type": [ - "object", - "null" - ], + "budget": { + "type": "object", "additionalProperties": { - "anyOf": [ - { - "type": "array", - "items": { - "type": "array", - "items": { - "type": "number" - } - } - }, - { - "type": "string" - } + "type": [ + "string", + "null" ] } }, - "geometryIssue": { - "readOnly": true, - "description": "Property geometryIssue.", + "variance": { "type": [ "string", "null" - ], - "enum": [ - "invalid_geometry", - "plan_unavailable", - "outside_ancestry", - "other_plan", - null ] }, - "hierarchyIssues": { - "readOnly": true, - "description": "Property hierarchyIssues.", - "type": "array", - "items": { - "type": "string" + "reconciliation": { + "type": "object", + "additionalProperties": { + "type": [ + "string", + "null" + ] } }, - "createdAt": { - "readOnly": true, - "description": "Property createdAt.", - "default": "", - "type": "string" - }, - "updatedAt": { - "readOnly": true, - "description": "Property updatedAt.", - "default": "", - "type": "string" + "procurement": { + "type": "object", + "additionalProperties": { + "type": [ + "string", + "null" + ] + } }, - "path": { - "readOnly": true, - "description": "Ancestor breadcrumb, root first, excluding this facility; opt-in on collections through includePath.", + "dossiers": { "type": "array", "items": { "type": "object", - "required": [ - "id", - "name", - "type" - ], - "properties": { - "id": { - "type": "string", - "format": "uuid" - }, - "name": { - "type": "string" - }, - "type": { - "type": "string", - "enum": [ - "site", - "building", - "floor", - "zone", - "area" - ] - } + "additionalProperties": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + } + ] } } } } } - ], - "description": "Generic organizational facilities (site/building/floor/zone/area)." + ], + "description": "Class MaintenanceEconomicResource\n\nDedicated financial endpoints exclude ordinary operational and client reports." + }, + "MaintenanceExport.AdjustMaintenanceExportInput-maintenance_export.write": { + "type": "object", + "description": "Class MaintenanceExportResource\nImmutable saved files and explicit import acknowledgements keep ERP transport separate from publication.", + "required": [ + "clientOperationId", + "reason" + ], + "properties": { + "clientOperationId": { + "format": "uuid", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "default": "", + "type": "string" + }, + "reason": { + "maxLength": 1000, + "default": "", + "type": "string" + } + } + }, + "MaintenanceExport.ConfirmMaintenanceExportInput-maintenance_export.write": { + "type": "object", + "description": "Class MaintenanceExportResource\nImmutable saved files and explicit import acknowledgements keep ERP transport separate from publication.", + "required": [ + "clientOperationId", + "externalImportReference" + ], + "properties": { + "clientOperationId": { + "format": "uuid", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "default": "", + "type": "string" + }, + "externalImportReference": { + "maxLength": 200, + "default": "", + "type": "string" + } + } + }, + "MaintenanceExport.CreateMaintenanceExportInput-maintenance_export.write": { + "type": "object", + "description": "Class MaintenanceExportResource\nImmutable saved files and explicit import acknowledgements keep ERP transport separate from publication.", + "required": [ + "clientOperationId", + "system" + ], + "properties": { + "clientOperationId": { + "format": "uuid", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "default": "", + "type": "string" + }, + "interventionIds": { + "minItems": 1, + "maxItems": 100, + "description": "published intervention identifiers", + "type": "array", + "items": { + "type": "string" + } + }, + "system": { + "maxLength": 40, + "default": "", + "type": "string" + }, + "includeInternalCosts": { + "default": false, + "type": "boolean" + } + } }, - "LegacyFacility.FacilityPlanOverlayOutput.jsonld-facility.read": { + "MaintenanceExport.MaintenanceExportOutput.jsonld-maintenance_export.read": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -57820,99 +71482,116 @@ { "type": "object", "properties": { - "attachmentId": { - "readOnly": true, - "description": "Property attachmentId.", - "default": "", + "id": { "type": "string" }, - "imageWidth": { - "readOnly": true, - "description": "Property imageWidth.", + "organizationId": { + "type": "string" + }, + "actorId": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "schemaVersion": { + "type": "integer" + }, + "system": { + "type": "string" + }, + "includeInternalCosts": { + "type": "boolean" + }, + "sourceInterventionIds": { + "description": "retained structured metadata", + "type": "array", + "items": { + "type": "string" + } + }, + "originalExportId": { "type": [ - "integer", + "string", "null" ] }, - "imageHeight": { - "readOnly": true, - "description": "Property imageHeight.", + "adjustmentOf": { "type": [ - "integer", + "string", "null" ] }, - "zones": { - "readOnly": true, - "description": "Property zones.", - "type": "array", - "items": { - "type": "object", - "additionalProperties": { - "anyOf": [ - { - "type": "array", - "items": { - "type": "array", - "items": { - "type": "number" - } - } - }, - { - "type": "string" - } - ] - } + "reason": { + "type": [ + "string", + "null" + ] + }, + "createdAt": { + "type": "string" + }, + "revision": { + "type": "integer" + }, + "state": { + "type": "string" + }, + "confirmation": { + "description": "retained structured metadata", + "type": [ + "object", + "null" + ], + "additionalProperties": { + "type": [ + "string", + "null" + ] } }, - "equipment": { - "readOnly": true, - "description": "Property equipment.", - "type": "array", - "items": { + "rowCount": { + "type": "integer" + }, + "costsComplete": { + "type": [ + "boolean", + "null" + ] + }, + "incompleteCostCount": { + "type": [ + "integer", + "null" + ] + }, + "files": { + "description": "retained structured metadata", + "type": "object", + "additionalProperties": { "type": "object", "additionalProperties": { "anyOf": [ { - "type": "number" + "type": "integer" }, { "type": "string" - }, - { - "type": "null" } ] } } }, - "geometryIssues": { - "readOnly": true, - "type": "array", - "items": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - }, - "equipmentIssues": { - "readOnly": true, - "type": "array", - "items": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } + "replayed": { + "default": false, + "type": "boolean" } } } ], - "description": "Generic organizational facilities (site/building/floor/zone/area)." + "description": "Class MaintenanceExportResource\nImmutable saved files and explicit import acknowledgements keep ERP transport separate from publication." }, - "LegacyFacility.GeocodeAddressOutput.jsonld-facility.read": { + "MaintenanceExport.MaintenanceExportReferenceOutput.jsonld-maintenance_export.read": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -57920,101 +71599,40 @@ { "type": "object", "properties": { - "displayName": { - "readOnly": true, - "description": "Property displayName.", - "default": "", + "id": { "type": "string" }, - "latitude": { - "readOnly": true, - "description": "Property latitude.", - "default": 0, - "type": "number" + "organizationId": { + "type": "string" }, - "longitude": { - "readOnly": true, - "description": "Property longitude.", - "default": 0, - "type": "number" + "resourceType": { + "type": "string" + }, + "resourceId": { + "type": "string" + }, + "system": { + "type": "string" + }, + "reference": { + "type": "string" + }, + "revision": { + "type": "integer" + }, + "updatedAt": { + "type": "string" + }, + "replayed": { + "default": false, + "type": "boolean" } } } ], - "description": "Generic organizational facilities (site/building/floor/zone/area)." - }, - "LegacyFacility.MoveFacilityInput-facility.write": { - "type": "object", - "description": "Generic organizational facilities (site/building/floor/zone/area).", - "required": [ - "parentFacilityId" - ], - "properties": { - "parentFacilityId": { - "format": "uuid", - "description": "Target parent facility identifier. Field is required; use null to detach from parent.", - "externalDocs": { - "url": "https://schema.org/identifier" - }, - "example": "550e8400-e29b-41d4-a716-446655440001", - "type": [ - "string", - "null" - ] - } - } - }, - "LegacyFacility.SetFacilityPlanGeometryInput-facility.write": { - "type": "object", - "description": "Generic organizational facilities (site/building/floor/zone/area).", - "required": [ - "attachmentId", - "points" - ], - "properties": { - "attachmentId": { - "format": "uuid", - "description": "Floor plan attachment identifier the geometry is bound to. Required together with points; use null on both to clear.", - "externalDocs": { - "url": "https://schema.org/identifier" - }, - "example": "550e8400-e29b-41d4-a716-446655440001", - "type": [ - "string", - "null" - ] - }, - "points": { - "minItems": 3, - "description": "Polygon points in normalized 0-1 image coordinates, at least 3. Required together with attachmentId; use null on both to clear.", - "example": [ - [ - 0.1, - 0.1 - ], - [ - 0.4, - 0.1 - ], - [ - 0.4, - 0.4 - ] - ], - "type": [ - "array", - "null" - ], - "items": { - "type": "array", - "items": { - "type": "number" - } - } - } - } + "description": "Class MaintenanceExportResource\nImmutable saved files and explicit import acknowledgements keep ERP transport separate from publication." }, - "LegacyFacility.SuggestAddressesOutput.jsonld-facility.read": { + "MaintenanceExport.MaintenanceExportSourceOutput.jsonld-maintenance_export.read": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -58022,143 +71640,137 @@ { "type": "object", "properties": { - "member": { - "description": "Property member.", - "type": "array", - "items": { - "$ref": "#/components/schemas/AddressSuggestionOutput.jsonld-facility.read" - } + "id": { + "type": "string" }, - "totalItems": { - "description": "Property totalItems.", - "default": 0, + "number": { "type": "integer" + }, + "name": { + "type": "string" + }, + "type": { + "type": "string" + }, + "publishedAt": { + "type": [ + "string", + "null" + ] + }, + "publicationId": { + "type": [ + "string", + "null" + ] + }, + "site": { + "description": "retained structured metadata", + "type": [ + "object", + "null" + ], + "additionalProperties": { + "type": "string" + } + }, + "customer": { + "description": "retained structured metadata", + "type": [ + "object", + "null" + ], + "additionalProperties": { + "type": "string" + } + }, + "snapshotState": { + "type": "string" + }, + "identityComplete": { + "type": "boolean" + }, + "ready": { + "type": "boolean" + }, + "blockedReason": { + "type": [ + "string", + "null" + ] } } } ], - "description": "Generic organizational facilities (site/building/floor/zone/area)." + "description": "Class MaintenanceExportResource\nImmutable saved files and explicit import acknowledgements keep ERP transport separate from publication." }, - "LegacyFacility.UpdateFacilityInput-facility.write.jsonMergePatch": { + "MaintenanceExport.WriteMaintenanceExportReferenceInput-maintenance_export.write.jsonMergePatch": { "type": "object", - "description": "Generic organizational facilities (site/building/floor/zone/area).", + "description": "Class MaintenanceExportResource\nImmutable saved files and explicit import acknowledgements keep ERP transport separate from publication.", "properties": { - "type": { - "enum": [ - "site", - "building", - "floor", - "zone", - "area" - ], - "description": "Facility type (partial update)", - "example": "building", - "type": [ - "string", - "null" - ] + "clientOperationId": { + "format": "uuid", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "default": "", + "type": "string" }, - "name": { - "minLength": 2, - "maxLength": 120, - "description": "Facility display name (partial update)", - "example": "Warehouse A", - "type": [ - "string", - "null" - ] + "system": { + "maxLength": 40, + "default": "", + "type": "string" }, - "code": { - "maxLength": 80, - "description": "Optional facility code", - "example": "BLD-WHS-A", - "type": [ - "string", - "null" - ] + "reference": { + "maxLength": 200, + "default": "", + "type": "string" + } + } + }, + "MaintenanceOccurrenceOutput.jsonld-maintenance_plan.read": { + "type": "object", + "properties": { + "id": { + "default": "", + "type": "string" }, - "address": { - "maxLength": 255, - "description": "Optional address", - "example": "12 avenue Victor Hugo, Lyon", - "type": [ - "string", - "null" - ] + "dueAt": { + "default": "", + "type": "string" }, - "latitude": { - "minimum": -90, - "maximum": 90, - "description": "Optional latitude, required together with longitude (partial update)", - "example": 45.764, - "type": [ - "number", - "null" - ] + "attempt": { + "default": 0, + "type": "integer" }, - "longitude": { - "minimum": -180, - "maximum": 180, - "description": "Optional longitude, required together with latitude (partial update)", - "example": 4.8357, + "interventionId": { "type": [ - "number", + "string", "null" ] }, - "metadata": { - "description": "Optional free-form metadata", - "example": { - "surfaceM2": 4500 - }, - "type": "object", - "additionalProperties": { - "type": [ - "string", - "null" - ] - } - }, - "levelIndex": { - "minimum": -100, - "maximum": 200, - "description": "Optional stacking order of the floor (partial update)", - "example": -1, + "number": { "type": [ "integer", "null" ] }, - "elevationMeters": { - "minimum": -10000, - "maximum": 10000, - "description": "Optional floor elevation in meters", - "example": -3, - "type": [ - "number", - "null" - ] + "status": { + "default": "open", + "type": "string" }, - "heightMeters": { - "minimum": 0, - "exclusiveMinimum": 0, - "maximum": 1000, - "description": "Optional floor height in meters", - "example": 3, - "type": [ - "number", - "null" - ] + "retryAllowed": { + "default": false, + "type": "boolean" } } }, - "LegacyInspection.CreateInspectionInput-inspection.write": { + "MaintenancePlan.ChangeMaintenancePlanInput-maintenance_plan.write": { "type": "object", - "description": "Inspection records for fire safety equipment.", + "description": "Organization-scoped operations retain immutable occurrence and legacy identities.", "properties": { - "clientId": { + "equipmentId": { "format": "uuid", - "description": "Client-generated UUID for idempotent offline creation", "externalDocs": { "url": "https://schema.org/identifier" }, @@ -58167,138 +71779,76 @@ "null" ] }, - "organization": { - "pattern": "^(/api/organizations/[0-9a-fA-F-]{36})$", - "description": "Organization IRI, required on the canonical endpoint", + "name": { + "minLength": 1, + "maxLength": 160, "type": [ "string", "null" ] }, - "intervention": { - "pattern": "^(/api/interventions/[0-9a-fA-F-]{36})$", - "description": "Optional field intervention IRI", + "operationKind": { + "enum": [ + "control", + "maintenance" + ], "type": [ "string", "null" ] }, - "equipmentId": { - "format": "uuid", - "description": "Equipment identifier", - "externalDocs": { - "url": "https://schema.org/identifier" - }, - "default": "", - "example": "550e8400-e29b-41d4-a716-446655440000", - "type": "string" - }, - "result": { - "enum": [ - "pass", - "fail", - "partial" - ], - "description": "Inspection result", - "default": "", - "example": "pass", - "type": "string" - }, - "performedAt": { - "description": "Date the inspection was performed (ISO 8601)", - "default": "", - "example": "2024-06-15T10:00:00+02:00", - "type": "string" - }, - "inspectorType": { - "enum": [ - "user", - "external" - ], - "description": "Inspector type (user or external)", - "default": "", - "example": "user", - "type": "string" - }, - "inspectorName": { - "maxLength": 255, - "description": "Inspector display name", - "default": "", - "example": "Jean Dupont", - "type": "string" - }, - "facilityId": { - "format": "uuid", - "description": "Optional facility identifier", - "externalDocs": { - "url": "https://schema.org/identifier" - }, + "interval": { + "minLength": 3, + "maxLength": 32, "type": [ "string", "null" ] - }, - "checklistId": { - "format": "uuid", - "description": "Optional checklist identifier", - "externalDocs": { - "url": "https://schema.org/identifier" - }, + }, + "anchorAt": { "type": [ "string", "null" ] }, - "inspectorUserId": { - "format": "uuid", - "description": "Optional user identifier (required for user type)", + "anchorOn": { "externalDocs": { - "url": "https://schema.org/identifier" + "url": "https://schema.org/Date" }, "type": [ "string", "null" ] }, - "inspectorOrganizationName": { - "maxLength": 255, - "description": "Optional external organization name", + "nextDueAt": { "type": [ "string", "null" ] }, - "notes": { - "maxLength": 5000, - "description": "Optional free-form notes", + "nextDueOn": { + "externalDocs": { + "url": "https://schema.org/Date" + }, "type": [ "string", "null" ] }, - "signature": { - "description": "Optional signature data", + "active": { "type": [ - "string", + "boolean", "null" ] } - }, - "required": [ - "equipmentId", - "result", - "performedAt", - "inspectorType", - "inspectorName" - ] + } }, - "LegacyInspection.EditInspectionInput-inspection.write.jsonMergePatch": { + "MaintenancePlan.ChangeMaintenancePlanInput-maintenance_plan.write.jsonMergePatch": { "type": "object", - "description": "Inspection records for fire safety equipment.", + "description": "Organization-scoped operations retain immutable occurrence and legacy identities.", "properties": { "equipmentId": { "format": "uuid", - "description": "Equipment identifier", "externalDocs": { "url": "https://schema.org/identifier" }, @@ -58307,59 +71857,81 @@ "null" ] }, - "facilityId": { - "format": "uuid", - "description": "Optional facility identifier", - "externalDocs": { - "url": "https://schema.org/identifier" - }, + "name": { + "minLength": 1, + "maxLength": 160, "type": [ "string", "null" ] }, - "checklistId": { - "format": "uuid", - "description": "Optional checklist identifier", + "operationKind": { + "enum": [ + "control", + "maintenance" + ], + "type": [ + "string", + "null" + ] + }, + "interval": { + "minLength": 3, + "maxLength": 32, + "type": [ + "string", + "null" + ] + }, + "anchorAt": { + "type": [ + "string", + "null" + ] + }, + "anchorOn": { "externalDocs": { - "url": "https://schema.org/identifier" + "url": "https://schema.org/Date" }, "type": [ "string", "null" ] }, - "result": { - "enum": [ - "pass", - "fail", - "partial" - ], - "description": "Inspection result", - "example": "pass", + "nextDueAt": { "type": [ "string", "null" ] }, - "performedAt": { - "description": "Date the inspection was performed (ISO 8601)", - "example": "2024-06-15T10:00:00+02:00", + "nextDueOn": { + "externalDocs": { + "url": "https://schema.org/Date" + }, "type": [ "string", "null" ] }, - "notes": { - "maxLength": 5000, - "description": "Optional free-form notes", + "active": { "type": [ - "string", + "boolean", "null" ] + } + } + }, + "MaintenancePlan.GenerateMaintenancePlanInput-maintenance_plan.write": { + "type": "object", + "description": "Organization-scoped operations retain immutable occurrence and legacy identities.", + "properties": { + "retry": { + "default": false, + "type": "boolean" }, - "signature": { - "description": "Optional signature data", + "name": { + "minLength": 1, + "maxLength": 160, "type": [ "string", "null" @@ -58367,7 +71939,7 @@ } } }, - "LegacyInspection.InspectionOutput.jsonld-inspection.read": { + "MaintenancePlan.GenerateMaintenancePlanOutput.jsonld-maintenance_plan.read": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -58375,192 +71947,166 @@ { "type": "object", "properties": { - "intervention": { - "description": "Property intervention.", - "type": [ - "string", - "null" - ] + "occurrenceId": { + "default": "", + "type": "string" }, - "recordStatus": { - "description": "Property recordStatus.", - "default": "published", + "interventionId": { + "default": "", "type": "string" }, - "revision": { - "description": "Property revision.", - "default": 1, + "number": { + "default": 0, + "type": "integer" + }, + "workItemsCount": { + "default": 0, + "type": "integer" + }, + "replayed": { + "default": false, + "type": "boolean" + } + } + } + ], + "description": "Organization-scoped operations retain immutable occurrence and legacy identities." + }, + "MaintenancePlan.MaintenancePlanEngineOutput.jsonld-maintenance_plan.read": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { + "mode": { + "default": "legacy", + "type": "string" + }, + "preparedCount": { + "default": 0, "type": "integer" }, + "conflicts": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + ], + "description": "Organization-scoped operations retain immutable occurrence and legacy identities." + }, + "MaintenancePlan.MaintenancePlanOutput.jsonld-maintenance_plan.read": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { + "calendarTimezone": { + "default": "UTC", + "type": "string" + }, "id": { - "readOnly": true, - "description": "Property id.", "default": "", "type": "string" }, "organizationId": { - "readOnly": true, - "description": "Property organizationId.", "default": "", "type": "string" }, "equipmentId": { - "readOnly": true, - "description": "Property equipmentId.", "default": "", "type": "string" }, "facilityId": { - "readOnly": true, - "description": "Property facilityId.", - "type": [ - "string", - "null" - ] - }, - "equipmentSerialNumber": { - "readOnly": true, - "description": "Serial number of the inspected equipment", - "type": [ - "string", - "null" - ] - }, - "facilityName": { - "readOnly": true, - "description": "Display name of the inspected facility", - "type": [ - "string", - "null" - ] - }, - "checklistName": { - "readOnly": true, - "description": "Display name of the followed checklist", "type": [ "string", "null" ] }, - "result": { - "readOnly": true, - "description": "Property result.", + "equipmentType": { "default": "", "type": "string" }, - "status": { - "readOnly": true, - "description": "Property status.", + "name": { "default": "", "type": "string" }, - "performedAt": { - "readOnly": true, - "description": "Property performedAt.", + "operationKind": { + "default": "control", + "type": "string" + }, + "interval": { "default": "", "type": "string" }, - "inspector": { - "readOnly": true, - "description": "Property inspector.", - "anyOf": [ - { - "$ref": "#/components/schemas/InspectorOutput.jsonld-inspection.read" - }, - { - "type": "null" - } + "cadenceMode": { + "default": "fixed", + "type": "string" + }, + "anchorAt": { + "type": [ + "string", + "null" ] }, - "checklistId": { - "readOnly": true, - "description": "Property checklistId.", + "nextDueAt": { "type": [ "string", "null" ] }, - "notes": { - "readOnly": true, - "description": "Property notes.", + "active": { + "default": false, + "type": "boolean" + }, + "legacyScheduleId": { "type": [ "string", "null" ] }, - "signature": { - "readOnly": true, - "description": "Property signature.", + "lastCompletedAt": { "type": [ "string", "null" ] }, - "nonConformitiesCount": { - "readOnly": true, - "description": "Property nonConformitiesCount.", - "default": 0, - "type": "integer" + "archivedAt": { + "type": [ + "string", + "null" + ] }, "createdAt": { - "readOnly": true, - "description": "Property createdAt.", "default": "", "type": "string" }, "updatedAt": { - "readOnly": true, - "description": "Property updatedAt.", "default": "", "type": "string" + }, + "openOccurrence": { + "anyOf": [ + { + "$ref": "#/components/schemas/MaintenanceOccurrenceOutput.jsonld-maintenance_plan.read" + }, + { + "type": "null" + } + ] } } } ], - "description": "Inspection records for fire safety equipment." - }, - "MaintenanceCampaign.GenerateInspectionCampaignInput": { - "type": "object", - "description": "Resource MaintenanceCampaignResource.\n\nGenerates an intervention draft (\"inspection campaign\") from an\norganization's due/overdue maintenance schedules.", - "required": [ - "organization", - "dueBefore" - ], - "properties": { - "organization": { - "description": "Property organization.", - "default": "", - "type": "string" - }, - "name": { - "minLength": 2, - "maxLength": 160, - "description": "Property name.", - "default": "", - "type": "string" - }, - "facility": { - "description": "Property facility.", - "type": [ - "string", - "null" - ] - }, - "equipmentType": { - "description": "Property equipmentType.", - "type": [ - "string", - "null" - ] - }, - "dueBefore": { - "description": "Property dueBefore.", - "default": "", - "type": "string" - } - } + "description": "Organization-scoped operations retain immutable occurrence and legacy identities." }, - "MaintenanceCampaign.GenerateInspectionCampaignOutput.jsonld": { + "MaintenancePlan.MaintenancePlanPreviewOutput.jsonld-maintenance_plan.read": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -58568,25 +72114,16 @@ { "type": "object", "properties": { - "interventionId": { - "description": "Property interventionId.", - "default": "", - "type": "string" - }, - "number": { - "description": "Property number.", - "default": 0, - "type": "integer" - }, - "workItemsCount": { - "description": "Property workItemsCount.", - "default": 0, - "type": "integer" + "dates": { + "type": "array", + "items": { + "type": "string" + } } } } ], - "description": "Resource MaintenanceCampaignResource.\n\nGenerates an intervention draft (\"inspection campaign\") from an\norganization's due/overdue maintenance schedules." + "description": "Organization-scoped operations retain immutable occurrence and legacy identities." }, "MaintenanceSchedule.MaintenanceScheduleOutput.jsonld": { "allOf": [ @@ -60330,6 +73867,15 @@ "example": "Fireguard Paris", "type": "string" }, + "operatingProfile": { + "enum": [ + "operator", + "service_provider" + ], + "description": "Operational profile; independent from access permissions", + "default": "operator", + "type": "string" + }, "slug": { "minLength": 3, "maxLength": 120, @@ -60366,6 +73912,12 @@ "default": "", "type": "string" }, + "operatingProfile": { + "readOnly": true, + "description": "Property operatingProfile.", + "default": "operator", + "type": "string" + }, "slug": { "readOnly": true, "description": "Property slug.", @@ -60607,6 +74159,17 @@ "null" ] }, + "operatingProfile": { + "enum": [ + "operator", + "service_provider" + ], + "description": "Operational profile; independent from access permissions", + "type": [ + "string", + "null" + ] + }, "slug": { "minLength": 3, "maxLength": 120, @@ -61457,10 +75020,189 @@ "mode": "none", "from": null, "to": null, - "metrics": [], - "health": { - "metrics": [] - } + "metrics": [], + "health": { + "metrics": [] + } + }, + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string" + }, + { + "type": "null" + } + ] + } + } + } + }, + { + "type": "array", + "items": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "integer" + }, + { + "type": "string" + }, + { + "type": "null" + } + ] + } + } + }, + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "trends": { + "readOnly": true, + "description": "Per-KPI running-total sparkline series, one point per day bucket\nacross the dashboard period (`period.from`..`period.to`). Exact for\nthe default \"ends near now\" window; an approximation anchored on\nthe current KPI total for explicitly historical windows.", + "default": { + "facilities": [], + "members": [], + "equipment": [], + "inspections": [] + }, + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + } + ] + } + } + } + }, + "recentInterventions": { + "readOnly": true, + "description": "The 5 most recently updated organization interventions across every\nstatus, ordered by `updatedAt` descending. Empty when the caller\nlacks `organization.interventions.read`.", + "type": "array", + "items": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + }, + { + "type": "null" + } + ] + } + } + } + } + } + ], + "description": "Organization dashboard analytics and KPIs. Supersedes the legacy /statistics widget endpoints." + }, + "OrganizationDashboardTrend.OrganizationDashboardTrendOutput.jsonld-Organization.read": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { + "generatedAt": { + "readOnly": true, + "description": "Property generatedAt", + "default": "", + "type": "string" + }, + "metric": { + "readOnly": true, + "description": "Property metric", + "default": "", + "type": "string" + }, + "period": { + "readOnly": true, + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "summary": { + "readOnly": true, + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "integer" + }, + { + "type": "string" + } + ] + } + }, + "series": { + "readOnly": true, + "type": "array", + "items": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string" + } + ] + } + } + }, + "comparison": { + "readOnly": true, + "default": { + "mode": "none", + "from": null, + "to": null, + "summary": [], + "series": [] }, "type": "object", "additionalProperties": { @@ -61468,23 +75210,14 @@ { "type": "object", "additionalProperties": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string" - }, - { - "type": "null" - } - ] + "anyOf": [ + { + "type": "number" + }, + { + "type": "integer" } - } + ] } }, { @@ -61493,17 +75226,11 @@ "type": "object", "additionalProperties": { "anyOf": [ - { - "type": "number" - }, { "type": "integer" }, { "type": "string" - }, - { - "type": "null" } ] } @@ -61518,15 +75245,9 @@ ] } }, - "trends": { + "seriesByMetric": { "readOnly": true, - "description": "Per-KPI running-total sparkline series, one point per day bucket\nacross the dashboard period (`period.from`..`period.to`). Exact for\nthe default \"ends near now\" window; an approximation anchored on\nthe current KPI total for explicitly historical windows.", - "default": { - "facilities": [], - "members": [], - "equipment": [], - "inspections": [] - }, + "description": "Per-metric series, keyed by metric identifier, populated only when the\n`metrics` filter requested more than the primary `metric` (e.g. the\ntwo-series non-conformities opened/resolved chart). Every entry shares\nthis payload's `period`, so the frontend can render several series on one\nchart without stitching independent calls together.", "type": "object", "additionalProperties": { "type": "array", @@ -61544,34 +75265,13 @@ } } } - }, - "recentInterventions": { - "readOnly": true, - "description": "The 5 most recently updated organization interventions across every\nstatus, ordered by `updatedAt` descending. Empty when the caller\nlacks `organization.interventions.read`.", - "type": "array", - "items": { - "type": "object", - "additionalProperties": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "string" - }, - { - "type": "null" - } - ] - } - } } } } ], - "description": "Organization dashboard analytics and KPIs. Supersedes the legacy /statistics widget endpoints." + "description": "Organization dashboard trend endpoints for chart-level requests with independent periods." }, - "OrganizationDashboardTrend.OrganizationDashboardTrendOutput.jsonld-Organization.read": { + "OrganizationEquipmentSummary.FacilityEquipmentSummaryOutput.jsonld": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -61579,134 +75279,69 @@ { "type": "object", "properties": { - "generatedAt": { - "readOnly": true, - "description": "Property generatedAt", - "default": "", - "type": "string" - }, - "metric": { - "readOnly": true, - "description": "Property metric", - "default": "", - "type": "string" - }, - "period": { - "readOnly": true, - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "summary": { + "scope": { "readOnly": true, - "type": "object", - "additionalProperties": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "integer" - }, - { - "type": "string" - } - ] - } + "description": "Property scope.", + "default": "subtree", + "type": "string", + "enum": [ + "subtree", + "direct" + ] }, - "series": { + "totalItems": { "readOnly": true, - "type": "array", - "items": { - "type": "object", - "additionalProperties": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "string" - } - ] - } - } + "description": "Property totalItems.", + "default": 0, + "type": "integer", + "minimum": 0 }, - "comparison": { + "byStatus": { "readOnly": true, + "description": "Property byStatus.", "default": { - "mode": "none", - "from": null, - "to": null, - "summary": [], - "series": [] + "in_stock": 0, + "operational": 0, + "under_maintenance": 0, + "decommissioned": 0 }, "type": "object", - "additionalProperties": { - "anyOf": [ - { - "type": "object", - "additionalProperties": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "integer" - } - ] - } - }, - { - "type": "array", - "items": { - "type": "object", - "additionalProperties": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "string" - } - ] - } - } - }, - { - "type": "string" - }, - { - "type": "null" - } - ] + "required": [ + "in_stock", + "operational", + "under_maintenance", + "decommissioned" + ], + "properties": { + "in_stock": { + "type": "integer", + "minimum": 0 + }, + "operational": { + "type": "integer", + "minimum": 0 + }, + "under_maintenance": { + "type": "integer", + "minimum": 0 + }, + "decommissioned": { + "type": "integer", + "minimum": 0 + } } }, - "seriesByMetric": { + "needingAttentionCount": { "readOnly": true, - "description": "Per-metric series, keyed by metric identifier, populated only when the\n`metrics` filter requested more than the primary `metric` (e.g. the\ntwo-series non-conformities opened/resolved chart). Every entry shares\nthis payload's `period`, so the frontend can render several series on one\nchart without stitching independent calls together.", - "type": "object", - "additionalProperties": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "string" - } - ] - } - } - } + "description": "Property needingAttentionCount.", + "default": 0, + "type": "integer", + "minimum": 0 } } } ], - "description": "Organization dashboard trend endpoints for chart-level requests with independent periods." + "description": "Organization parc summary within the caller's selected customer and family." }, "OrganizationInvitation.AcceptOrganizationInvitationInput-Organization.write": { "type": "object", @@ -63734,6 +77369,130 @@ ], "description": "OTP configuration discovery endpoints." }, + "ParkAnomaly.NonConformityOutput.jsonld-inspection.read": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { + "id": { + "readOnly": true, + "description": "Property id", + "default": "", + "type": "string" + }, + "inspectionId": { + "readOnly": true, + "description": "Property inspectionId", + "default": "", + "type": "string" + }, + "description": { + "readOnly": true, + "description": "Property description", + "default": "", + "type": "string" + }, + "severity": { + "readOnly": true, + "description": "Property severity", + "default": "", + "type": "string" + }, + "status": { + "readOnly": true, + "description": "Property status", + "default": "", + "type": "string" + }, + "dueAt": { + "readOnly": true, + "description": "Property dueAt", + "type": [ + "string", + "null" + ] + }, + "resolvedAt": { + "readOnly": true, + "description": "Property resolvedAt", + "type": [ + "string", + "null" + ] + }, + "notes": { + "readOnly": true, + "description": "Property notes", + "type": [ + "string", + "null" + ] + }, + "equipmentId": { + "readOnly": true, + "description": "Identifier of the inspected equipment", + "type": [ + "string", + "null" + ] + }, + "equipmentSerialNumber": { + "readOnly": true, + "description": "Serial number of the inspected equipment", + "type": [ + "string", + "null" + ] + }, + "createdAt": { + "readOnly": true, + "description": "Property createdAt", + "default": "", + "type": "string" + }, + "updatedAt": { + "readOnly": true, + "description": "Property updatedAt", + "default": "", + "type": "string" + } + } + } + ], + "description": "Organization parc anomaly queue, backed by published inspection findings." + }, + "ParkAnomaly.ParkAnomaliesSummaryOutput.jsonld-inspection.read": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { + "openAnomalies": { + "default": 0, + "type": "integer" + }, + "bySeverity": { + "default": { + "low": 0, + "medium": 0, + "high": 0, + "critical": 0 + }, + "type": "object", + "additionalProperties": { + "type": "integer" + } + } + } + } + ], + "description": "Organization parc anomaly queue, backed by published inspection findings." + }, "PasswordChange.ConfirmPasswordChangeInput-password_change.write": { "type": "object", "description": "Authenticated password change operations (OTP protected)", @@ -64434,93 +78193,467 @@ "null" ] }, - "description": { - "maxLength": 2000, - "description": "Plan description", + "description": { + "maxLength": 2000, + "description": "Plan description", + "type": [ + "string", + "null" + ] + }, + "limits": { + "description": "Per-resource quantity caps (members, facilities, equipment, inspections); omit a resource for unlimited", + "type": [ + "object", + "null" + ], + "additionalProperties": { + "type": "integer" + } + }, + "isActive": { + "description": "Whether the plan can be selected", + "type": [ + "boolean", + "null" + ] + }, + "isDefault": { + "description": "Whether the plan is the catalog default", + "type": [ + "boolean", + "null" + ] + }, + "sortOrder": { + "description": "Display order", + "type": [ + "integer", + "null" + ] + } + } + }, + "PlanQuotaOutput.jsonld-Plan.read": { + "type": "object", + "properties": { + "resource": { + "readOnly": true, + "description": "Property resource.", + "default": "", + "type": "string" + }, + "label": { + "readOnly": true, + "description": "Property label.", + "default": "", + "type": "string" + }, + "limit": { + "readOnly": true, + "description": "Property limit.", + "type": [ + "integer", + "null" + ] + }, + "summary": { + "readOnly": true, + "description": "Property summary.", + "default": "", + "type": "string" + } + } + }, + "Presence.PingPresenceInput": { + "type": "object", + "description": "Resource PresenceResource.\n\nOnline presence (L2.7) — **no database table**. Backed entirely by\n`Shared\\Application\\Port\\Outbound\\CachePort` (Redis in production, the\nfilesystem adapter in dev/test — see `MODULE.md`'s per-process caveat)\nwith a 90 second TTL per member. The client is expected to call\n`POST /presence/ping` roughly every 60 seconds while active, and to\nread presence for the specific member ids it is currently displaying via\n`GET /presence` — there is deliberately NO \"list all online members\"\nendpoint (see `MODULE.md`).", + "required": [ + "organization" + ], + "properties": { + "organization": { + "description": "Property organization.", + "default": "", + "type": "string" + } + } + }, + "Presence.PingPresenceOutput.jsonld": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { + "memberId": { + "readOnly": true, + "description": "Property memberId.", + "default": "", + "type": "string" + }, + "lastSeenAt": { + "readOnly": true, + "description": "Property lastSeenAt.", + "default": "", + "type": "string" + } + } + } + ], + "description": "Resource PresenceResource.\n\nOnline presence (L2.7) — **no database table**. Backed entirely by\n`Shared\\Application\\Port\\Outbound\\CachePort` (Redis in production, the\nfilesystem adapter in dev/test — see `MODULE.md`'s per-process caveat)\nwith a 90 second TTL per member. The client is expected to call\n`POST /presence/ping` roughly every 60 seconds while active, and to\nread presence for the specific member ids it is currently displaying via\n`GET /presence` — there is deliberately NO \"list all online members\"\nendpoint (see `MODULE.md`)." + }, + "Presence.PresenceOutput.jsonld": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { + "memberId": { + "description": "Property memberId.", + "default": "", + "type": "string" + }, + "online": { + "description": "Property online.", + "default": false, + "type": "boolean" + }, + "status": { + "description": "Property status", + "default": "offline", + "type": "string", + "enum": [ + "active", + "do_not_disturb", + "offline" + ] + }, + "lastSeenAt": { + "description": "Property lastSeenAt.", + "type": [ + "string", + "null" + ] + } + } + } + ], + "description": "Resource PresenceResource.\n\nOnline presence (L2.7) — **no database table**. Backed entirely by\n`Shared\\Application\\Port\\Outbound\\CachePort` (Redis in production, the\nfilesystem adapter in dev/test — see `MODULE.md`'s per-process caveat)\nwith a 90 second TTL per member. The client is expected to call\n`POST /presence/ping` roughly every 60 seconds while active, and to\nread presence for the specific member ids it is currently displaying via\n`GET /presence` — there is deliberately NO \"list all online members\"\nendpoint (see `MODULE.md`)." + }, + "Presence.PresenceSubscriptionOutput.jsonld-presence.read": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { + "topic": { + "description": "Property topic", + "default": "", + "type": "string" + }, + "token": { + "description": "Property token", + "default": "", + "type": "string" + }, + "expiresAt": { + "description": "Property expiresAt", + "default": "", + "type": "string" + } + } + } + ], + "description": "Resource PresenceResource.\n\nOnline presence (L2.7) — **no database table**. Backed entirely by\n`Shared\\Application\\Port\\Outbound\\CachePort` (Redis in production, the\nfilesystem adapter in dev/test — see `MODULE.md`'s per-process caveat)\nwith a 90 second TTL per member. The client is expected to call\n`POST /presence/ping` roughly every 60 seconds while active, and to\nread presence for the specific member ids it is currently displaying via\n`GET /presence` — there is deliberately NO \"list all online members\"\nendpoint (see `MODULE.md`)." + }, + "PresencePreference.PresencePreferenceOutput.jsonld-presence-preference.read": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { + "doNotDisturb": { + "description": "Property doNotDisturb", + "default": false, + "type": "boolean" + }, + "revision": { + "description": "Property revision", + "default": 0, + "type": "integer" + }, + "invisible": { + "description": "Property invisible", + "default": false, + "type": "boolean" + } + } + } + ], + "description": "Service PresencePreferenceResource." + }, + "PresencePreference.PresencePreferenceSubscriptionOutput.jsonld-presence-preference.read": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { + "topic": { + "description": "Property topic", + "default": "", + "type": "string" + }, + "token": { + "description": "Property token", + "default": "", + "type": "string" + }, + "expiresAt": { + "description": "Property expiresAt", + "default": "", + "type": "string" + } + } + } + ], + "description": "Service PresencePreferenceResource." + }, + "PresencePreference.UpdatePresencePreferenceInput-presence-preference.write.jsonMergePatch": { + "type": "object", + "description": "Service PresencePreferenceResource.", + "properties": { + "doNotDisturb": { + "description": "Property doNotDisturb", + "type": "boolean" + }, + "invisible": { + "description": "Property invisible", + "type": "boolean" + } + } + }, + "Procurement.ChangePurchaseOrderInput-procurement.write.jsonMergePatch": { + "type": "object", + "description": "Exact and idempotent internal procurement; commercial billing remains in the ERP.", + "properties": { + "name": { + "type": [ + "string", + "null" + ] + }, + "supplierId": { + "format": "uuid", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "type": [ + "string", + "null" + ] + }, + "lines": { + "type": [ + "array", + "null" + ], + "items": { + "type": "object", + "additionalProperties": { + "type": [ + "string", + "null" + ] + } + } + } + } + }, + "Procurement.ChangeSupplierInput-procurement.write.jsonMergePatch": { + "type": "object", + "description": "Exact and idempotent internal procurement; commercial billing remains in the ERP.", + "properties": { + "name": { + "type": [ + "string", + "null" + ] + }, + "code": { + "type": [ + "string", + "null" + ] + }, + "email": { + "type": [ + "string", + "null" + ] + }, + "phone": { + "type": [ + "string", + "null" + ] + }, + "contacts": { + "type": [ + "array", + "null" + ], + "items": { + "type": "object", + "additionalProperties": { + "type": [ + "string", + "null" + ] + } + } + } + } + }, + "Procurement.CreatePurchaseOrderInput-procurement.write": { + "type": "object", + "description": "Exact and idempotent internal procurement; commercial billing remains in the ERP.", + "properties": { + "name": { + "description": "Property name", + "type": [ + "string", + "null" + ] + }, + "clientOperationId": { + "format": "uuid", + "description": "Property clientOperationId", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "type": [ + "string", + "null" + ] + }, + "supplierId": { + "format": "uuid", + "description": "Property supplierId", + "externalDocs": { + "url": "https://schema.org/identifier" + }, "type": [ "string", "null" ] }, - "limits": { - "description": "Per-resource quantity caps (members, facilities, equipment, inspections); omit a resource for unlimited", + "lines": { + "description": "Property lines", "type": [ - "object", + "array", "null" ], - "additionalProperties": { - "type": "integer" + "items": { + "type": "object", + "additionalProperties": { + "type": [ + "string", + "null" + ] + } } - }, - "isActive": { - "description": "Whether the plan can be selected", + } + } + }, + "Procurement.CreateSupplierInput-procurement.write": { + "type": "object", + "description": "Exact and idempotent internal procurement; commercial billing remains in the ERP.", + "properties": { + "name": { + "description": "Property name", "type": [ - "boolean", + "string", "null" ] }, - "isDefault": { - "description": "Whether the plan is the catalog default", + "clientOperationId": { + "format": "uuid", + "description": "Property clientOperationId", + "externalDocs": { + "url": "https://schema.org/identifier" + }, "type": [ - "boolean", + "string", "null" ] }, - "sortOrder": { - "description": "Display order", + "code": { + "description": "Property code", "type": [ - "integer", + "string", "null" ] - } - } - }, - "PlanQuotaOutput.jsonld-Plan.read": { - "type": "object", - "properties": { - "resource": { - "readOnly": true, - "description": "Property resource.", - "default": "", - "type": "string" }, - "label": { - "readOnly": true, - "description": "Property label.", - "default": "", - "type": "string" + "email": { + "description": "Property email", + "type": [ + "string", + "null" + ] }, - "limit": { - "readOnly": true, - "description": "Property limit.", + "phone": { + "description": "Property phone", "type": [ - "integer", + "string", "null" ] }, - "summary": { - "readOnly": true, - "description": "Property summary.", - "default": "", - "type": "string" + "contacts": { + "description": "Property contacts", + "type": [ + "array", + "null" + ], + "items": { + "type": "object", + "additionalProperties": { + "type": [ + "string", + "null" + ] + } + } } } }, - "Presence.PingPresenceInput": { + "Procurement.IndividualizeReceiptInput-procurement.write": { "type": "object", - "description": "Resource PresenceResource.\n\nOnline presence (L2.7) — **no database table**. Backed entirely by\n`Shared\\Application\\Port\\Outbound\\CachePort` (Redis in production, the\nfilesystem adapter in dev/test — see `MODULE.md`'s per-process caveat)\nwith a 90 second TTL per member. The client is expected to call\n`POST /presence/ping` roughly every 60 seconds while active, and to\nread presence for the specific member ids it is currently displaying via\n`GET /presence` — there is deliberately NO \"list all online members\"\nendpoint (see `MODULE.md`).", + "description": "Exact and idempotent internal procurement; commercial billing remains in the ERP.", "required": [ - "organization" + "clientOperationId" ], "properties": { - "organization": { - "description": "Property organization.", + "clientOperationId": { + "format": "uuid", + "externalDocs": { + "url": "https://schema.org/identifier" + }, "default": "", "type": "string" } } }, - "Presence.PingPresenceOutput.jsonld": { + "Procurement.ProcurementReceiptOutput.jsonld-procurement.read": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -64528,53 +78661,78 @@ { "type": "object", "properties": { - "memberId": { - "readOnly": true, - "description": "Property memberId.", - "default": "", + "pendingReturnQuantity": { + "default": "0.000000", "type": "string" }, - "lastSeenAt": { - "readOnly": true, - "description": "Property lastSeenAt.", - "default": "", + "id": { "type": "string" - } - } - } - ], - "description": "Resource PresenceResource.\n\nOnline presence (L2.7) — **no database table**. Backed entirely by\n`Shared\\Application\\Port\\Outbound\\CachePort` (Redis in production, the\nfilesystem adapter in dev/test — see `MODULE.md`'s per-process caveat)\nwith a 90 second TTL per member. The client is expected to call\n`POST /presence/ping` roughly every 60 seconds while active, and to\nread presence for the specific member ids it is currently displaying via\n`GET /presence` — there is deliberately NO \"list all online members\"\nendpoint (see `MODULE.md`)." - }, - "Presence.PresenceOutput.jsonld": { - "allOf": [ - { - "$ref": "#/components/schemas/HydraItemBaseSchema" - }, - { - "type": "object", - "properties": { - "memberId": { - "description": "Property memberId.", - "default": "", + }, + "organizationId": { "type": "string" }, - "online": { - "description": "Property online.", - "default": false, - "type": "boolean" + "orderId": { + "type": "string" + }, + "lineId": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "quantity": { + "type": "string" + }, + "warehouseId": { + "type": [ + "string", + "null" + ] + }, + "currency": { + "type": "string" + }, + "receivedAt": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "inventoryMovementId": { + "type": [ + "string", + "null" + ] + }, + "equipmentIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "returnedQuantity": { + "type": "string" }, "status": { - "description": "Property status", - "default": "offline", - "type": "string", - "enum": [ - "active", - "do_not_disturb", - "offline" + "type": "string" + }, + "blockedReason": { + "type": [ + "string", + "null" ] }, - "lastSeenAt": { - "description": "Property lastSeenAt.", + "revision": { + "type": "integer" + }, + "financialVisible": { + "type": "boolean" + }, + "replayed": { + "default": false, + "type": "boolean" + }, + "unitCost": { "type": [ "string", "null" @@ -64583,9 +78741,9 @@ } } ], - "description": "Resource PresenceResource.\n\nOnline presence (L2.7) — **no database table**. Backed entirely by\n`Shared\\Application\\Port\\Outbound\\CachePort` (Redis in production, the\nfilesystem adapter in dev/test — see `MODULE.md`'s per-process caveat)\nwith a 90 second TTL per member. The client is expected to call\n`POST /presence/ping` roughly every 60 seconds while active, and to\nread presence for the specific member ids it is currently displaying via\n`GET /presence` — there is deliberately NO \"list all online members\"\nendpoint (see `MODULE.md`)." + "description": "Exact and idempotent internal procurement; commercial billing remains in the ERP." }, - "Presence.PresenceSubscriptionOutput.jsonld-presence.read": { + "Procurement.ProcurementReturnOutput.jsonld-procurement.read": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -64593,27 +78751,61 @@ { "type": "object", "properties": { - "topic": { - "description": "Property topic", - "default": "", + "id": { "type": "string" }, - "token": { - "description": "Property token", - "default": "", + "organizationId": { "type": "string" }, - "expiresAt": { - "description": "Property expiresAt", - "default": "", + "receiptId": { + "type": "string" + }, + "clientOperationId": { + "type": "string" + }, + "quantity": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "status": { + "type": "string" + }, + "inventoryMovementId": { + "type": [ + "string", + "null" + ] + }, + "blockedReason": { + "type": [ + "string", + "null" + ] + }, + "createdAt": { "type": "string" + }, + "reconciledAt": { + "type": [ + "string", + "null" + ] + }, + "revision": { + "type": "integer" + }, + "replayed": { + "default": false, + "type": "boolean" } } } ], - "description": "Resource PresenceResource.\n\nOnline presence (L2.7) — **no database table**. Backed entirely by\n`Shared\\Application\\Port\\Outbound\\CachePort` (Redis in production, the\nfilesystem adapter in dev/test — see `MODULE.md`'s per-process caveat)\nwith a 90 second TTL per member. The client is expected to call\n`POST /presence/ping` roughly every 60 seconds while active, and to\nread presence for the specific member ids it is currently displaying via\n`GET /presence` — there is deliberately NO \"list all online members\"\nendpoint (see `MODULE.md`)." + "description": "Exact and idempotent internal procurement; commercial billing remains in the ERP." }, - "PresencePreference.PresencePreferenceOutput.jsonld-presence-preference.read": { + "Procurement.PurchaseOrderOutput.jsonld-procurement.read": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -64621,27 +78813,141 @@ { "type": "object", "properties": { - "doNotDisturb": { - "description": "Property doNotDisturb", - "default": false, + "id": { + "type": "string" + }, + "organizationId": { + "type": "string" + }, + "supplierId": { + "type": "string" + }, + "name": { + "type": "string" + }, + "currency": { + "type": "string" + }, + "status": { + "type": "string" + }, + "lines": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "object", + "additionalProperties": { + "type": [ + "string", + "null" + ] + } + } + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "financialVisible": { "type": "boolean" }, "revision": { - "description": "Property revision", - "default": 0, "type": "integer" }, - "invisible": { - "description": "Property invisible", + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + }, + "replayed": { "default": false, "type": "boolean" } } } ], - "description": "Service PresencePreferenceResource." + "description": "Exact and idempotent internal procurement; commercial billing remains in the ERP." }, - "PresencePreference.PresencePreferenceSubscriptionOutput.jsonld-presence-preference.read": { + "Procurement.ReceivePurchaseOrderInput-procurement.write": { + "type": "object", + "description": "Exact and idempotent internal procurement; commercial billing remains in the ERP.", + "required": [ + "lineId", + "quantity", + "receivedAt", + "clientOperationId" + ], + "properties": { + "lineId": { + "format": "uuid", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "default": "", + "type": "string" + }, + "warehouseId": { + "format": "uuid", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "type": [ + "string", + "null" + ] + }, + "quantity": { + "default": "", + "type": "string" + }, + "receivedAt": { + "default": "", + "type": "string" + }, + "clientOperationId": { + "format": "uuid", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "default": "", + "type": "string" + } + } + }, + "Procurement.ReturnProcurementReceiptInput-procurement.write": { + "type": "object", + "description": "Exact and idempotent internal procurement; commercial billing remains in the ERP.", + "required": [ + "quantity", + "reason", + "clientOperationId" + ], + "properties": { + "quantity": { + "default": "", + "type": "string" + }, + "reason": { + "default": "", + "type": "string" + }, + "clientOperationId": { + "format": "uuid", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "default": "", + "type": "string" + } + } + }, + "Procurement.SupplierOutput.jsonld-procurement.read": { "allOf": [ { "$ref": "#/components/schemas/HydraItemBaseSchema" @@ -64649,39 +78955,78 @@ { "type": "object", "properties": { - "topic": { - "description": "Property topic", - "default": "", + "id": { "type": "string" }, - "token": { - "description": "Property token", - "default": "", + "organizationId": { "type": "string" }, - "expiresAt": { - "description": "Property expiresAt", - "default": "", + "name": { + "type": "string" + }, + "code": { + "type": [ + "string", + "null" + ] + }, + "email": { + "type": [ + "string", + "null" + ] + }, + "phone": { + "type": [ + "string", + "null" + ] + }, + "contacts": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "object", + "additionalProperties": { + "type": [ + "string", + "null" + ] + } + } + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + }, + "archivedAt": { + "type": [ + "string", + "null" + ] + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { "type": "string" + }, + "revision": { + "type": "integer" + }, + "replayed": { + "default": false, + "type": "boolean" } } } ], - "description": "Service PresencePreferenceResource." - }, - "PresencePreference.UpdatePresencePreferenceInput-presence-preference.write.jsonMergePatch": { - "type": "object", - "description": "Service PresencePreferenceResource.", - "properties": { - "doNotDisturb": { - "description": "Property doNotDisturb", - "type": "boolean" - }, - "invisible": { - "description": "Property invisible", - "type": "boolean" - } - } + "description": "Exact and idempotent internal procurement; commercial billing remains in the ERP." }, "Publication.CreatePublicationInput": { "type": "object", @@ -65010,6 +79355,103 @@ } } }, + "ReplacementSuccessorInput-equipment.write": { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "maxLength": 32, + "description": "Property type", + "default": "", + "type": "string" + }, + "subType": { + "maxLength": 100, + "description": "Property subType", + "type": [ + "string", + "null" + ] + }, + "brand": { + "maxLength": 100, + "description": "Property brand", + "type": [ + "string", + "null" + ] + }, + "model": { + "maxLength": 100, + "description": "Property model", + "type": [ + "string", + "null" + ] + }, + "serialNumber": { + "maxLength": 100, + "description": "Property serialNumber", + "type": [ + "string", + "null" + ] + }, + "locationLabel": { + "maxLength": 255, + "description": "Property locationLabel", + "type": [ + "string", + "null" + ] + }, + "name": { + "maxLength": 255, + "description": "Property name", + "type": [ + "string", + "null" + ] + }, + "assetCode": { + "maxLength": 100, + "description": "Property assetCode", + "type": [ + "string", + "null" + ] + }, + "criticality": { + "enum": [ + "low", + "medium", + "high", + "critical" + ], + "description": "Property criticality", + "type": [ + "string", + "null" + ] + }, + "technicalProperties": { + "maxItems": 50, + "description": "Property technicalProperties", + "type": "array", + "items": { + "type": "object", + "additionalProperties": { + "type": [ + "string", + "null" + ] + } + } + } + } + }, "Role.AddPermissionInput": { "type": "object", "description": "Role-Based Access Control (RBAC) role management. Roles group permissions together.", @@ -65273,6 +79715,370 @@ ], "description": "Dated archives of the regulatory \"registre de sécurité\" PDF. Requires organization.compliance.export AND a pro/max plan." }, + "ServiceRequest.ConvertServiceRequestInput-service_request.write": { + "type": "object", + "description": "Class ServiceRequestResource. Organization-owned internal repair request workflow. @category Resource", + "required": [ + "clientOperationId" + ], + "properties": { + "clientOperationId": { + "format": "uuid", + "description": "Property clientOperationId.", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "default": "", + "type": "string" + }, + "existingInterventionId": { + "format": "uuid", + "description": "Property existingInterventionId.", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "type": [ + "string", + "null" + ] + }, + "existingTaskId": { + "format": "uuid", + "description": "Property existingTaskId.", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "type": [ + "string", + "null" + ] + } + } + }, + "ServiceRequest.CreateServiceRequestInput-service_request.write": { + "type": "object", + "description": "Class ServiceRequestResource. Organization-owned internal repair request workflow. @category Resource", + "properties": { + "equipmentId": { + "format": "uuid", + "description": "Property equipmentId.", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "type": [ + "string", + "null" + ] + }, + "siteId": { + "format": "uuid", + "description": "Property siteId.", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "type": [ + "string", + "null" + ] + }, + "title": { + "maxLength": 160, + "description": "Property title.", + "default": "", + "type": "string" + }, + "description": { + "maxLength": 10000, + "description": "Property description.", + "default": "", + "type": "string" + }, + "priority": { + "enum": [ + "low", + "normal", + "high", + "urgent" + ], + "description": "Property priority.", + "default": "normal", + "type": "string" + }, + "originInspectionId": { + "format": "uuid", + "description": "Property originInspectionId.", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "type": [ + "string", + "null" + ] + }, + "originNonConformityId": { + "format": "uuid", + "description": "Property originNonConformityId.", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "title", + "description" + ] + }, + "ServiceRequest.DecisionServiceRequestInput-service_request.write": { + "type": "object", + "description": "Class ServiceRequestResource. Organization-owned internal repair request workflow. @category Resource", + "required": [ + "reason" + ], + "properties": { + "reason": { + "maxLength": 2000, + "description": "Property reason.", + "default": "", + "type": "string" + } + } + }, + "ServiceRequest.QualifyServiceRequestInput-service_request.write": { + "type": "object", + "description": "Class ServiceRequestResource. Organization-owned internal repair request workflow. @category Resource", + "properties": { + "equipmentId": { + "format": "uuid", + "description": "Property equipmentId.", + "externalDocs": { + "url": "https://schema.org/identifier" + }, + "type": [ + "string", + "null" + ] + }, + "note": { + "maxLength": 10000, + "description": "Property note.", + "type": [ + "string", + "null" + ] + } + } + }, + "ServiceRequest.ServiceRequestOutput.jsonld-service_request.read": { + "allOf": [ + { + "$ref": "#/components/schemas/HydraItemBaseSchema" + }, + { + "type": "object", + "properties": { + "id": { + "description": "Property id.", + "default": "", + "type": "string" + }, + "organizationId": { + "description": "Property organizationId.", + "default": "", + "type": "string" + }, + "equipmentId": { + "description": "Property equipmentId.", + "type": [ + "string", + "null" + ] + }, + "siteId": { + "description": "Property siteId.", + "type": [ + "string", + "null" + ] + }, + "title": { + "description": "Property title.", + "default": "", + "type": "string" + }, + "description": { + "description": "Property description.", + "default": "", + "type": "string" + }, + "priority": { + "description": "Property priority.", + "default": "normal", + "type": "string" + }, + "status": { + "description": "Property status.", + "default": "requested", + "type": "string" + }, + "originInspectionId": { + "description": "Property originInspectionId.", + "type": [ + "string", + "null" + ] + }, + "originNonConformityId": { + "description": "Property originNonConformityId.", + "type": [ + "string", + "null" + ] + }, + "qualificationNote": { + "description": "Property qualificationNote.", + "type": [ + "string", + "null" + ] + }, + "decisionReason": { + "description": "Property decisionReason.", + "type": [ + "string", + "null" + ] + }, + "interventionId": { + "description": "Property interventionId.", + "type": [ + "string", + "null" + ] + }, + "taskId": { + "description": "Property taskId.", + "type": [ + "string", + "null" + ] + }, + "requestedAt": { + "description": "Property requestedAt.", + "default": "", + "type": "string" + }, + "updatedAt": { + "description": "Property updatedAt.", + "default": "", + "type": "string" + }, + "qualifiedAt": { + "description": "Property qualifiedAt.", + "type": [ + "string", + "null" + ] + }, + "rejectedAt": { + "description": "Property rejectedAt.", + "type": [ + "string", + "null" + ] + }, + "cancelledAt": { + "description": "Property cancelledAt.", + "type": [ + "string", + "null" + ] + }, + "convertedAt": { + "description": "Property convertedAt.", + "type": [ + "string", + "null" + ] + }, + "revision": { + "description": "Property revision.", + "default": 1, + "type": "integer" + }, + "targetSnapshot": { + "default": { + "equipment": null, + "site": null, + "customer": null + }, + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "object", + "additionalProperties": { + "type": [ + "string", + "null" + ] + } + }, + { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + { + "type": "null" + } + ] + } + } + } + } + ], + "description": "Class ServiceRequestResource. Organization-owned internal repair request workflow. @category Resource" + }, + "ServiceRequest.UpdateServiceRequestInput-service_request.write.jsonMergePatch": { + "type": "object", + "description": "Class ServiceRequestResource. Organization-owned internal repair request workflow. @category Resource", + "properties": { + "title": { + "maxLength": 160, + "description": "Property title.", + "type": [ + "string", + "null" + ] + }, + "description": { + "maxLength": 10000, + "description": "Property description.", + "type": [ + "string", + "null" + ] + }, + "priority": { + "enum": [ + "low", + "normal", + "high", + "urgent" + ], + "description": "Property priority.", + "type": [ + "string", + "null" + ] + } + } + }, "Session.RevokeOtherSessionsOutput.jsonld-session.revoke_others": { "allOf": [ { @@ -65980,10 +80786,22 @@ "type": "string" }, "versions": { + "description": "bounded latest revision; older durable history is paged separately", "type": "array", "items": { "$ref": "#/components/schemas/TimeEntryVersionView.jsonld" } + }, + "totalVersions": { + "description": "complete durable revision count", + "type": "integer" + }, + "nextBeforeRevision": { + "description": "exclusive cursor for older retained revisions", + "type": [ + "integer", + "null" + ] } } }, @@ -67691,6 +82509,10 @@ "name": "Teams", "description": "Organization team management." }, + { + "name": "Customer", + "description": "Class CustomerResource. Internal customer management inside a single organization. @category Resource" + }, { "name": "Onboarding", "description": "Organization onboarding orchestration endpoints." @@ -67701,7 +82523,7 @@ }, { "name": "Equipment", - "description": "Resource FacilityEquipmentSummaryResource." + "description": "Organization parc summary within the caller's selected customer and family." }, { "name": "Media", @@ -67709,16 +82531,24 @@ }, { "name": "Inspection", - "description": "Organization-wide non-conformity statistics for the compliance KPI surfaces." + "description": "Organization parc anomaly queue, backed by published inspection findings." }, { "name": "Checklist", "description": "Versioned checklist templates for inspections." }, + { + "name": "EquipmentInspectionSummary", + "description": "Resource EquipmentInspectionSummaryResource." + }, { "name": "InspectionResponse", "description": "Resource InspectionResponseResource." }, + { + "name": "EquipmentOpenWork", + "description": "Class EquipmentOpenWorkResource\n\nProvides a scoped lookup without changing equipment-read permissions." + }, { "name": "InterventionActivity", "description": "Resource InterventionActivityResource.\n\nThe intervention activity feed: member-authored comments and\nsystem-recorded lifecycle events (creation, status transitions)." @@ -67804,11 +82634,11 @@ "description": "Resource MaintenanceCampaignResource.\n\nGenerates an intervention draft (\"inspection campaign\") from an\norganization's due/overdue maintenance schedules." }, { - "name": "MaintenanceSchedule", + "name": "Maintenance", "description": "Resource MaintenanceScheduleResource.\n\nOrganization-scoped preventive-maintenance schedules: one row per tracked\npiece of equipment, computed from the equipment's inspection history and\nthe organization's compliance periodicity (or a per-equipment override)." }, { - "name": "Maintenance", + "name": "MaintenanceSchedule", "description": "Resource MaintenanceScheduleResource.\n\nOrganization-scoped preventive-maintenance schedules: one row per tracked\npiece of equipment, computed from the equipment's inspection history and\nthe organization's compliance periodicity (or a per-equipment override)." }, { @@ -67883,6 +82713,50 @@ "name": "Calendar", "description": "Member-scoped iCal calendar feed subscription token." }, + { + "name": "ServiceRequest", + "description": "Class ServiceRequestResource. Organization-owned internal repair request workflow. @category Resource" + }, + { + "name": "Inventory", + "description": "Manage warehouses. @category Resource" + }, + { + "name": "InventoryConsumption", + "description": "Declare complete physical consumptions, retaining shortages. @category Resource" + }, + { + "name": "InventoryMovement", + "description": "Read immutable motivated stock movements. @category Resource" + }, + { + "name": "InventoryPart", + "description": "Manage quantitative part and consumable references. @category Resource" + }, + { + "name": "InventoryWarehouse", + "description": "Manage warehouses. @category Resource" + }, + { + "name": "Inventorycorrections", + "description": "Motivated stock corrections. @category Resource" + }, + { + "name": "Inventoryreturns", + "description": "Motivated stock returns. @category Resource" + }, + { + "name": "Procurement", + "description": "Exact and idempotent internal procurement; commercial billing remains in the ERP." + }, + { + "name": "MaintenanceCost", + "description": "Class MaintenanceEconomicResource\n\nDedicated financial endpoints exclude ordinary operational and client reports." + }, + { + "name": "Maintenance export", + "description": "Class MaintenanceExportResource\nImmutable saved files and explicit import acknowledgements keep ERP transport separate from publication." + }, { "name": "Authentication", "description": "Resource FederatedAuthResource.\n\nDeclares the Google and Microsoft sign-in, callback and connection API." diff --git a/tests/contracts/fixtures/source.json b/tests/contracts/fixtures/source.json index 31e940cf5..4018c0392 100644 --- a/tests/contracts/fixtures/source.json +++ b/tests/contracts/fixtures/source.json @@ -1,6 +1,6 @@ { "repository": "DevSkyLex/fireguard-api", "source": "openapi.json", - "sha256": "e86da0bd561dc92111d45c60de94d703a7ab562e94ee78e63a81e8eca0b423e6", - "commit": "ff491ffc86b08436181313a30df31741520c7633" + "sha256": "6e6a53d42fe5cc5e98173fdd6facff8064b59719b0a1d01694a89857c5c92740", + "commit": "9d5e63a6e602175d3e66bc433d2fa69e281d05e2" } diff --git a/tests/e2e/README.md b/tests/e2e/README.md index 54c3c8dd0..c65514ff4 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -16,6 +16,9 @@ would hang waiting on a backend that isn't running. `angular.json` therefore defines an `e2e` build/serve configuration (`ssr: false`, `outputMode: "static"`) — a client-only SPA build. `tests/e2e/playwright.config.ts`'s `webServer` runs it automatically for `npm run e2e:*`. +It disables component HMR: document reloads can cancel development-only replacement +imports and report module errors after a route has rendered. Normal lazy route imports +and strict browser-console assertions remain enabled. ## Run coordination diff --git a/tests/e2e/harness/api-mock.spec.ts b/tests/e2e/harness/api-mock.spec.ts index 46d08c65a..00c548c62 100644 --- a/tests/e2e/harness/api-mock.spec.ts +++ b/tests/e2e/harness/api-mock.spec.ts @@ -15,6 +15,7 @@ import { facilityOutput, facilityPlanOverlayOutput, } from '../support/fixtures/facility-fixtures'; +import { interventionOutput } from '../support/fixtures/intervention-fixtures'; import { API_BASE_URL, ApiMock } from '../support/mocks/api-mock'; const families = [ @@ -102,6 +103,127 @@ for (const family of families) { } } +const inventoryResources = ['inventory-parts', 'inventory-warehouses', 'inventory-consumptions']; +const inventoryIntervention = interventionOutput(); + +for (const registration of ['list', 'detail', 'queues', 'create'] as const) { + test(`prepares an empty optional inventory snapshot for the ${registration} intervention fixture`, async ({ + page, + }) => { + const api = new ApiMock(page); + if (registration === 'list') + await api.mockInterventionList(E2E_ORGANIZATION_ID, [inventoryIntervention]); + else if (registration === 'detail') await api.mockInterventionDetail(inventoryIntervention); + else if (registration === 'queues') + await api.mockInterventionQueues({ upcoming: [inventoryIntervention] }); + else await api.mockInterventionCreate(inventoryIntervention); + const responses = await page.evaluate( + async ({ base, organization, intervention, resources }) => + Promise.all( + resources.map(async (resource) => { + const scope = + resource === 'inventory-consumptions' + ? `interventionId=${intervention}` + : 'archived=false'; + const response = await fetch( + `${base}/api/organizations/${organization}/${resource}?page=1&itemsPerPage=100&${scope}`, + ); + return { status: response.status, body: await response.json() }; + }), + ), + { + base: API_BASE_URL, + organization: E2E_ORGANIZATION_ID, + intervention: inventoryIntervention.id, + resources: inventoryResources, + }, + ); + for (const response of responses) { + expect(response.status).toBe(200); + expect(response.body).toMatchObject({ member: [], totalItems: 0 }); + } + }); +} + +for (const resource of inventoryResources) { + test(`keeps ${resource} defaults bounded to the fixture read contract`, async ({ + page, + }, info) => { + await new ApiMock(page).mockInterventionDetail(inventoryIntervention); + const statuses = await page.evaluate( + async ({ base, organization, intervention, resource: targetResource }) => { + const scope = + targetResource === 'inventory-consumptions' + ? `interventionId=${intervention}` + : 'archived=false'; + const target = `${base}/api/organizations/${organization}/${targetResource}?page=1&itemsPerPage=100&${scope}`; + const requests = [ + fetch(target), + fetch(target, { method: 'POST' }), + fetch(target.replace(organization, 'wrong-org')), + fetch(target.replace('page=1', 'page=2')), + fetch(target.replace('itemsPerPage=100', 'itemsPerPage=30')), + fetch( + target.replace( + scope, + targetResource === 'inventory-consumptions' + ? 'interventionId=unknown' + : 'archived=true', + ), + ), + fetch(target.replace(`&${scope}`, '')), + fetch(`${target}&search=unregistered`), + ]; + return Promise.all(requests.map(async (request) => (await request).status)); + }, + { + base: API_BASE_URL, + organization: E2E_ORGANIZATION_ID, + intervention: inventoryIntervention.id, + resource, + }, + ); + expect(statuses).toEqual([200, 404, 404, 404, 404, 404, 404, 404]); + await expect + .poll( + () => info.errors.filter((error) => error.message?.includes('Hermetic safety net')).length, + ) + .toBe(7); + test.fail( + true, + 'Verified every out-of-scope inventory request reached the hermetic safety net.', + ); + }); +} + +test('retains explicit inventory fixtures after detail refresh and registers additional intervention identities', async ({ + page, +}) => { + const api = new ApiMock(page); + await api.mockInterventionDetail(inventoryIntervention); + const path = `${API_BASE_URL}/api/organizations/${E2E_ORGANIZATION_ID}/inventory-parts`; + await page.route(`${path}?*`, (route) => + route.fulfill({ + contentType: 'application/ld+json', + body: JSON.stringify({ member: [{ id: 'scenario-part' }], totalItems: 1 }), + }), + ); + await api.mockInterventionDetail({ ...inventoryIntervention, revision: 2 }); + await api.mockInterventionDetail({ ...inventoryIntervention, id: 'second-intervention' }); + const responses = await page.evaluate( + async ({ path: partsPath, base, organization }) => { + const parts = await fetch(`${partsPath}?page=1&itemsPerPage=100&archived=false`); + const declarations = await fetch( + `${base}/api/organizations/${organization}/inventory-consumptions?page=1&itemsPerPage=100&interventionId=second-intervention`, + ); + return { parts: await parts.json(), declarations: declarations.status }; + }, + { path, base: API_BASE_URL, organization: E2E_ORGANIZATION_ID }, + ); + expect(responses.parts).toMatchObject({ member: [{ id: 'scenario-part' }], totalItems: 1 }); + expect(responses.declarations).toBe(200); +}); + test('fails even when an unexpected API request is handled by the page', async ({ page }, info) => { await new ApiMock(page).mockSavedMessages(); await page.evaluate(async () => { diff --git a/tests/e2e/harness/interaction-mode.spec.ts b/tests/e2e/harness/interaction-mode.spec.ts new file mode 100644 index 000000000..0545e592b --- /dev/null +++ b/tests/e2e/harness/interaction-mode.spec.ts @@ -0,0 +1,57 @@ +import { expect, test, devices } from '@playwright/test'; +import { emulateMobilePlatform } from '../support/helpers/interaction-mode'; + +for (const platform of ['ios', 'ipad'] as const) { + test(`keeps ${platform} device evidence consistent with the browser's native touch context`, async ({ + browser, + }) => { + const context = await browser.newContext({ + ...devices[platform === 'ios' ? 'iPhone 12' : 'iPad Pro 11 landscape'], + serviceWorkers: 'block', + }); + try { + await emulateMobilePlatform(context, platform); + const page = await context.newPage(); + await page.route('http://mobile-harness.test/', (route) => + route.fulfill({ + contentType: 'text/html', + body: '', + }), + ); + await page.goto('http://mobile-harness.test/'); + const signals = await page.evaluate(() => ({ + platform: navigator.platform, + touchPoints: navigator.maxTouchPoints, + coarsePointer: matchMedia('(pointer: coarse)').matches, + hover: matchMedia('(hover: hover)').matches, + })); + expect(signals.platform).toBe(platform === 'ios' ? 'iPhone' : 'MacIntel'); + expect(signals.touchPoints).toBeGreaterThan(0); + expect(signals.coarsePointer).toBe(true); + expect(signals.hover).toBe(false); + await page.getByRole('button', { name: 'Touch action', exact: true }).tap(); + await expect(page.getByRole('button', { name: 'Tapped', exact: true })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Tapped', exact: true })).toHaveAttribute( + 'data-touched', + 'true', + ); + } finally { + await context.close(); + } + }); + + test(`preserves native desktop touch evidence when ${platform} platform emulation has no touch preset`, async ({ + page, + context, + }) => { + const nativeTouchPoints = await page.evaluate(() => navigator.maxTouchPoints); + await emulateMobilePlatform(context, platform); + await page.route('http://desktop-harness.test/', (route) => + route.fulfill({ contentType: 'text/html', body: '

Desktop device harness

' }), + ); + await page.goto('http://desktop-harness.test/'); + expect(await page.evaluate(() => navigator.maxTouchPoints)).toBe(nativeTouchPoints); + expect(await page.evaluate(() => matchMedia('(pointer: coarse)').matches)).toBe(false); + expect(await page.evaluate(() => matchMedia('(hover: hover)').matches)).toBe(true); + }); +} diff --git a/tests/e2e/harness/ssr-stub.spec.ts b/tests/e2e/harness/ssr-stub.spec.ts index aa42bb27a..8c76e087b 100644 --- a/tests/e2e/harness/ssr-stub.spec.ts +++ b/tests/e2e/harness/ssr-stub.spec.ts @@ -1,4 +1,4 @@ -import { createServer } from 'node:http'; +import { createServer, type ServerResponse } from 'node:http'; import { createRequire } from 'node:module'; import { resolve } from 'node:path'; import { expect, test } from '@playwright/test'; @@ -74,3 +74,125 @@ test('serves the real HTTP stub locally and records wrong methods and endpoints' await new Promise((done) => server.close(() => done())); } }); + +test('keeps the authenticated Mercure stream open with exact origin CORS', async ({ + page, +}, info) => { + const documentServer = createServer((_request, response) => { + response.writeHead(200, { 'Content-Type': 'text/html' }); + response.end('

SSE harness

'); + }); + await new Promise((done) => documentServer.listen(0, '127.0.0.1', done)); + let server: ReturnType | undefined; + let stream: ServerResponse | undefined; + let finished = false; + let closed = false; + try { + const documentAddress = documentServer.address(); + if (!documentAddress || typeof documentAddress === 'string') + throw new Error('No local document port.'); + const appOrigin = 'http://127.0.0.1:' + documentAddress.port; + const stub = createApiStub(appOrigin); + server = createServer(stub.handler); + server.on('request', (request, response) => { + if (!request.url?.startsWith('/.well-known/mercure?')) return; + stream = response; + response.once('finish', () => { + finished = true; + }); + response.once('close', () => { + closed = true; + }); + }); + const apiServer = server; + await new Promise((done) => apiServer.listen(0, '127.0.0.1', done)); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('No local stub port.'); + const apiOrigin = 'http://127.0.0.1:' + address.port; + await page.goto(appOrigin + '/'); + const hub = new URL('/.well-known/mercure', apiOrigin); + hub.searchParams.set('topic', '/users/e2e-user-1/presence-preference'); + hub.searchParams.set('authorization', 'e2e-mercure-token'); + const state = await page.evaluateHandle((url) => { + const source = new EventSource(url); + const streamState = { source, errors: 0 }; + source.addEventListener('error', () => streamState.errors++); + return streamState; + }, hub.toString()); + try { + const connection = await state.evaluate( + (connectionState) => + new Promise<{ open: boolean; credentials: boolean; errors: number }>((complete) => { + const { source } = connectionState; + const snapshot = () => ({ + open: source.readyState === EventSource.OPEN, + credentials: source.withCredentials, + errors: connectionState.errors, + }); + if (source.readyState !== EventSource.CONNECTING) return complete(snapshot()); + const onOpen = () => { + source.removeEventListener('error', onError); + complete(snapshot()); + }; + const onError = () => { + source.removeEventListener('open', onOpen); + complete(snapshot()); + }; + source.addEventListener('open', onOpen, { once: true }); + source.addEventListener('error', onError, { once: true }); + }), + ); + await info.attach('mercure-transport', { + body: JSON.stringify({ + connection, + status: stream?.statusCode, + headers: stream?.getHeaders(), + }), + contentType: 'application/json', + }); + expect(connection).toEqual({ open: true, credentials: false, errors: 0 }); + if (!stream) throw new Error('Mercure must reach the real local HTTP stub.'); + expect(stream.statusCode).toBe(200); + expect(stream.getHeader('Content-Type')).toMatch(/^text\/event-stream\b/); + expect(stream.getHeader('Access-Control-Allow-Origin')).toBe(appOrigin); + expect(stream.getHeader('Access-Control-Allow-Credentials')).toBe('true'); + expect(stream.getHeader('Vary')).toBe('Origin'); + await page.evaluate( + async (url) => (await fetch(url)).text(), + apiOrigin + '/__harness/health', + ); + expect( + await state.evaluate(({ source, errors }) => ({ + open: source.readyState === EventSource.OPEN, + credentials: source.withCredentials, + errors, + })), + ).toEqual({ open: true, credentials: false, errors: 0 }); + expect(stream.writableEnded).toBe(false); + expect(stream.destroyed).toBe(false); + expect(finished).toBe(false); + expect(closed).toBe(false); + expect(stub.requests).toEqual([ + expect.objectContaining({ method: 'GET', path: '/.well-known/mercure', status: 200 }), + ]); + expect(stub.unexpected).toEqual([]); + await state.evaluate(({ source }) => source.close()); + expect(await state.evaluate(({ source }) => source.readyState === EventSource.CLOSED)).toBe( + true, + ); + await expect.poll(() => closed).toBe(true); + expect(finished).toBe(false); + } finally { + await state.evaluate(({ source }) => source.close()).catch(() => undefined); + await state.dispose(); + } + } finally { + if (server) { + const apiServer = server; + apiServer.closeAllConnections(); + await new Promise((done) => apiServer.close(() => done())); + } + documentServer.closeAllConnections(); + await new Promise((done) => documentServer.close(() => done())); + } +}); diff --git a/tests/e2e/onboarding/onboarding-steps.spec.ts b/tests/e2e/onboarding/onboarding-steps.spec.ts index faebc82f4..4f2ac659e 100644 --- a/tests/e2e/onboarding/onboarding-steps.spec.ts +++ b/tests/e2e/onboarding/onboarding-steps.spec.ts @@ -124,7 +124,7 @@ test.describe('Onboarding wizard — steps 2 through 5', () => { await onboarding.equipmentSerialInput.fill('SN-E2E-001'); await onboarding.equipmentSubmit.click(); - await expect(page).toHaveURL(new RegExp(`/organizations/${E2E_ORGANIZATION_ID}$`), { + await expect(page).toHaveURL(new RegExp(`/organizations/${E2E_ORGANIZATION_ID}/assets$`), { timeout: 10_000, }); await expect(onboarding.completedToast).toBeVisible(); diff --git a/tests/e2e/onboarding/onboarding.spec.ts b/tests/e2e/onboarding/onboarding.spec.ts index 50be1f279..764eb5a21 100644 --- a/tests/e2e/onboarding/onboarding.spec.ts +++ b/tests/e2e/onboarding/onboarding.spec.ts @@ -87,7 +87,7 @@ test.describe('Onboarding guard chain', () => { await expect(page).toHaveURL(/\/onboarding\/create\?returnUrl=%2Forganizations$/); }); - test('redirects /onboarding to the dashboard when the record is already completed', async ({ + test('redirects /onboarding to the equipment park when the record is already completed', async ({ page, }) => { const api = new ApiMock(page); @@ -96,7 +96,7 @@ test.describe('Onboarding guard chain', () => { await page.goto('/onboarding'); - await expect(page).toHaveURL(new RegExp(`/organizations/${E2E_ORGANIZATION_ID}$`), { + await expect(page).toHaveURL(new RegExp(`/organizations/${E2E_ORGANIZATION_ID}/assets$`), { timeout: 10_000, }); }); diff --git a/tests/e2e/organization/dashboard-redesign.spec.ts b/tests/e2e/organization/dashboard-redesign.spec.ts index 3d78035bb..407d3edfa 100644 --- a/tests/e2e/organization/dashboard-redesign.spec.ts +++ b/tests/e2e/organization/dashboard-redesign.spec.ts @@ -87,6 +87,30 @@ for (const width of [1440, 390]) { ); await expect(risk.getByTestId('donut-chart')).toBeVisible(); await expect(risk.locator('dl > div')).toHaveCount(4); + const queues = page.getByTestId('park-action-queues'); + const trends = page.getByTestId('org-dashboard-trends'); + await expect(queues.locator('app-stat-tile')).toHaveCount(3); + const queueBounds = await queues.boundingBox(); + const trendsBounds = await trends.boundingBox(); + if (!queueBounds || !trendsBounds) throw new Error('Missing queue or analytics bounds'); + expect(queueBounds.y + queueBounds.height).toBeLessThanOrEqual(trendsBounds.y); + if (width === 1440) { + expect(queueBounds.y + queueBounds.height).toBeLessThanOrEqual(900); + const tiles = await queues.locator('app-stat-tile').all(); + const tileBounds = await Promise.all( + tiles.map(async (tile) => { + await expect(tile).toBeInViewport({ ratio: 1 }); + const bounds = await tile.boundingBox(); + if (!bounds) throw new Error('Missing park action tile bounds'); + return bounds; + }), + ); + expect( + Math.max(...tileBounds.map((box) => box.y)) - + Math.min(...tileBounds.map((box) => box.y)), + ).toBeLessThanOrEqual(1); + await trends.scrollIntoViewIfNeeded(); + } const a = await activity.boundingBox(); const b = await risk.boundingBox(); if (!a || !b) throw new Error('Missing chart bounds'); diff --git a/tests/e2e/organization/equipment-catalog.spec.ts b/tests/e2e/organization/equipment-catalog.spec.ts new file mode 100644 index 000000000..ecfd67f7a --- /dev/null +++ b/tests/e2e/organization/equipment-catalog.spec.ts @@ -0,0 +1,199 @@ +import { expect, test, type Route } from '@playwright/test'; +import { E2E_ORGANIZATION_ID } from '../support/fixtures/api-fixtures'; +import { + collectConsoleErrors, + expectNoHorizontalOverflow, + expectNoInternalOverflow, + setDarkTheme, +} from '../support/helpers/appearance'; +import { pagePolishScreenshotDir } from '../support/helpers/screenshot-dir'; +import { ApiMock } from '../support/mocks/api-mock'; +import { EquipmentCatalogPage } from '../support/pages/equipment-catalog.page'; + +interface CatalogueEntry { + readonly '@id': string; + readonly '@type': string; + readonly value: string; + readonly label: string; + readonly family: 'fire' | 'safety' | 'other'; + readonly archived: boolean; + readonly revision: number; +} + +const cataloguePath = `/api/organizations/${E2E_ORGANIZATION_ID}/equipment-types`; + +function seed(): CatalogueEntry { + return { + '@id': `${cataloguePath}/fire_extinguisher`, + '@type': 'EquipmentType', + value: 'fire_extinguisher', + label: 'Fire extinguisher', + family: 'fire', + archived: false, + revision: 1, + }; +} + +async function json(route: Route, status: number, body: unknown): Promise { + await route.fulfill({ status, contentType: 'application/ld+json', body: JSON.stringify(body) }); +} + +test.describe('Equipment catalogue', () => { + test('creates a safety type and archives then restores it using the reviewed revision', async ({ + page, + }, testInfo) => { + const api = new ApiMock(page); + await api.mockAuthenticatedSession(); + const entries: CatalogueEntry[] = [seed()]; + await api.mockEquipmentTypeCatalog(E2E_ORGANIZATION_ID, entries); + const commands: unknown[] = []; + await page.route(new RegExp(`${cataloguePath}(\\?.*)?$`), async (route) => { + if (route.request().method() !== 'POST') return route.fallback(); + const input = route.request().postDataJSON() as { + value: string; + label: string; + family: CatalogueEntry['family']; + }; + commands.push(input); + const created: CatalogueEntry = { + ...input, + '@id': `${cataloguePath}/${input.value}`, + '@type': 'EquipmentType', + archived: false, + revision: 1, + }; + entries.push(created); + await json(route, 201, created); + }); + await page.route(new RegExp(`${cataloguePath}/gas_sensor(\\?.*)?$`), async (route) => { + if (route.request().method() !== 'PATCH') return route.fallback(); + const input = route.request().postDataJSON() as { revision: number; archived: boolean }; + commands.push(input); + const index = entries.findIndex((entry) => entry.value === 'gas_sensor'); + const current = entries[index]; + if (!current) throw new Error('Missing created catalogue descriptor'); + expect(input.revision).toBe(current.revision); + const updated = { ...current, archived: input.archived, revision: current.revision + 1 }; + entries[index] = updated; + await json(route, 200, updated); + }); + const catalogue = new EquipmentCatalogPage(page); + await catalogue.goto(E2E_ORGANIZATION_ID); + await expect(catalogue.title).toBeVisible(); + await catalogue.create('gas_sensor', 'Gas sensor', 'Safety'); + await expect(catalogue.editor).toBeHidden(); + await expect(catalogue.row('gas_sensor')).toContainText('Safety'); + await page.screenshot({ + path: `${pagePolishScreenshotDir(testInfo.project.name)}/equipment-catalog-desktop-light.png`, + animations: 'disabled', + }); + await catalogue.row('gas_sensor').getByRole('button', { name: 'Archive', exact: true }).click(); + await expect(catalogue.row('gas_sensor')).toHaveCount(0); + await catalogue.archived.click(); + await expect(catalogue.row('gas_sensor')).toBeVisible(); + await catalogue.row('gas_sensor').getByRole('button', { name: 'Restore', exact: true }).click(); + await expect(catalogue.row('gas_sensor')).toHaveCount(0); + await catalogue.active.click(); + await expect(catalogue.row('gas_sensor')).toBeVisible(); + expect(commands).toEqual([ + { value: 'gas_sensor', label: 'Gas sensor', family: 'safety' }, + { revision: 1, archived: true }, + { revision: 2, archived: false }, + ]); + }); + + test('preserves a rejected draft while reviewing a concurrent catalogue revision', async ({ + page, + }, testInfo) => { + const api = new ApiMock(page); + await api.mockAuthenticatedSession(); + const entries: CatalogueEntry[] = [ + { ...seed(), value: 'fire_blanket', label: 'Fire blanket', revision: 3 }, + ]; + await api.mockEquipmentTypeCatalog(E2E_ORGANIZATION_ID, entries); + const commands: unknown[] = []; + await page.route(new RegExp(`${cataloguePath}/fire_blanket(\\?.*)?$`), async (route) => { + if (route.request().method() !== 'PATCH') return route.fallback(); + const input = route.request().postDataJSON() as { + revision: number; + label: string; + family: CatalogueEntry['family']; + }; + commands.push(input); + if (commands.length === 1) { + entries[0] = { ...entries[0], label: 'Fire blanket revised elsewhere', revision: 4 }; + await json(route, 409, { + status: 409, + title: 'Conflict', + detail: 'Equipment type revision changed.', + }); + return; + } + expect(input.revision).toBe(4); + entries[0] = { ...entries[0], label: input.label, family: input.family, revision: 5 }; + await json(route, 200, entries[0]); + }); + const catalogue = new EquipmentCatalogPage(page); + await catalogue.goto(E2E_ORGANIZATION_ID); + await catalogue.edit('fire_blanket'); + await expect(catalogue.code).toBeDisabled(); + await catalogue.name.fill('My retained blanket name'); + await catalogue.editor.getByRole('button', { name: 'Save type', exact: true }).click(); + await expect( + catalogue.editor.getByText('Equipment type revision changed.', { exact: true }), + ).toBeVisible(); + await expect(catalogue.name).toHaveValue('My retained blanket name'); + await catalogue.editor + .getByRole('button', { name: 'Review latest revision', exact: true }) + .click(); + await expect(catalogue.editor.getByText(/Fire blanket revised elsewhere/)).toBeVisible(); + await expect(catalogue.name).toHaveValue('My retained blanket name'); + await page.screenshot({ + path: `${pagePolishScreenshotDir(testInfo.project.name)}/equipment-catalog-conflict-editor-light.png`, + animations: 'disabled', + }); + await catalogue.editor.getByRole('button', { name: 'Save type', exact: true }).click(); + await expect(catalogue.editor).toBeHidden(); + await expect(catalogue.row('fire_blanket')).toContainText('My retained blanket name'); + expect(commands).toEqual([ + { revision: 3, label: 'My retained blanket name', family: 'fire' }, + { revision: 4, label: 'My retained blanket name', family: 'fire' }, + ]); + }); + + test('keeps the catalogue editor readable and keyboard operable on a narrow dark desktop viewport', async ({ + page, + context, + baseURL, + }, testInfo) => { + const errors = collectConsoleErrors(page); + await setDarkTheme(context, baseURL ?? 'http://localhost:4273'); + await page.setViewportSize({ width: 375, height: 812 }); + const api = new ApiMock(page); + await api.mockAuthenticatedSession(); + await api.mockEquipmentTypeCatalog(E2E_ORGANIZATION_ID, [seed()]); + const catalogue = new EquipmentCatalogPage(page); + await catalogue.goto(E2E_ORGANIZATION_ID); + await catalogue.newType.click(); + await catalogue.code.fill('smoke_sensor'); + await catalogue.name.fill('Smoke sensor for the north building and service corridor'); + await expect(catalogue.editor).toBeVisible(); + await catalogue.code.focus(); + await page.keyboard.press('Tab'); + await expect(catalogue.name).toBeFocused(); + await expectNoHorizontalOverflow(page); + await expectNoInternalOverflow(catalogue.editor); + await page.screenshot({ + path: `${pagePolishScreenshotDir(testInfo.project.name)}/equipment-catalog-editor-narrow-dark.png`, + animations: 'disabled', + }); + await catalogue.editor.getByRole('button', { name: 'Cancel', exact: true }).click(); + const discard = page.getByRole('alertdialog'); + await expect(discard).toBeVisible(); + await discard.getByRole('button', { name: /cancel/i }).click(); + await expect(catalogue.name).toHaveValue( + 'Smoke sensor for the north building and service corridor', + ); + expect(errors, errors.join('\n')).toEqual([]); + }); +}); diff --git a/tests/e2e/organization/equipment-replacement.spec.ts b/tests/e2e/organization/equipment-replacement.spec.ts new file mode 100644 index 000000000..7ee2157f8 --- /dev/null +++ b/tests/e2e/organization/equipment-replacement.spec.ts @@ -0,0 +1,196 @@ +import { expect, test, type Page, type Route } from '@playwright/test'; +import { E2E_ORGANIZATION_ID } from '../support/fixtures/api-fixtures'; +import { + E2E_EQUIPMENT_ID, + equipmentOutput, + inStockEquipmentOutput, +} from '../support/fixtures/equipment-fixtures'; +import { + expectNoHorizontalOverflow, + expectNoInternalOverflow, + setDarkTheme, +} from '../support/helpers/appearance'; +import { pagePolishScreenshotDir } from '../support/helpers/screenshot-dir'; +import { ApiMock } from '../support/mocks/api-mock'; +import { EquipmentsPage } from '../support/pages/equipments.page'; + +const replacementPath = `/api/organizations/${E2E_ORGANIZATION_ID}/equipment/${E2E_EQUIPMENT_ID}/replace`; + +interface ReplacementCommand { + readonly clientOperationId: string; + readonly successorEquipmentId?: string; + readonly successor?: { + readonly type: string; + readonly name?: string; + readonly assetCode?: string; + }; +} + +async function receipt( + route: Route, + command: ReplacementCommand, + successorId: string, + replayed: boolean, +): Promise { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + predecessorEquipmentId: E2E_EQUIPMENT_ID, + successorEquipmentId: successorId, + clientOperationId: command.clientOperationId, + replayed, + }), + }); +} + +async function openReplacement(page: Page): Promise { + const equipment = new EquipmentsPage(page); + await equipment.gotoDetail(E2E_ORGANIZATION_ID, E2E_EQUIPMENT_ID); + await expect(equipment.detailRoot).toBeVisible(); + await equipment.moreMenu.click(); + await page.getByTestId('equipment-replace').click(); + await expect(page.getByTestId('equipment-replacement-sheet')).toBeVisible(); +} + +test.describe('Equipment replacement', () => { + test('requires final confirmation before replacing equipment from reserve and preserves both dossiers', async ({ + page, + }, testInfo) => { + const api = new ApiMock(page); + await api.mockAuthenticatedSession(); + const old = { ...equipmentOutput(), name: 'Extinguisher EXT-01', assetCode: 'EXT-01' }; + const reserve = { + ...inStockEquipmentOutput(), + name: 'Reserve extinguisher', + assetCode: 'EXT-R02', + }; + const successor = { + ...reserve, + facilityId: old.facilityId, + facilityName: old.facilityName, + status: 'operational', + predecessorEquipmentId: old.id, + }; + await api.mockEquipmentDetail(E2E_ORGANIZATION_ID, old); + await api.mockEquipmentDetail(E2E_ORGANIZATION_ID, successor); + await api.mockEquipmentList(E2E_ORGANIZATION_ID, [reserve]); + const commands: ReplacementCommand[] = []; + await page.route(new RegExp(`${replacementPath}(\\?.*)?$`), async (route) => { + if (route.request().method() !== 'POST') return route.fallback(); + const command = route.request().postDataJSON() as ReplacementCommand; + commands.push(command); + await receipt(route, command, reserve.id, false); + }); + await openReplacement(page); + const sheet = page.getByTestId('equipment-replacement-sheet'); + const successorInput = sheet.getByLabel('Successor equipment', { exact: true }); + await successorInput.click(); + await successorInput.fill('Reserve'); + await page.getByRole('option', { name: /Reserve extinguisher/ }).click(); + await sheet.getByTestId('equipment-replacement-confirm').click(); + const confirmation = page.getByTestId('equipment-replacement-confirm-dialog'); + await expect(confirmation).toBeVisible(); + expect(commands).toEqual([]); + await page.screenshot({ + path: `${pagePolishScreenshotDir(testInfo.project.name)}/equipment-replacement-reserve-confirm-light.png`, + animations: 'disabled', + }); + await confirmation.getByRole('button', { name: 'Cancel', exact: true }).click(); + await expect(confirmation).toBeHidden(); + await expect(sheet).toBeVisible(); + expect(commands).toEqual([]); + await sheet.getByTestId('equipment-replacement-confirm').click(); + await page.getByTestId('equipment-replacement-final-confirm').click(); + await expect(sheet).toBeHidden(); + expect(commands).toHaveLength(1); + expect(commands[0]).toMatchObject({ successorEquipmentId: reserve.id }); + expect(commands[0]?.clientOperationId).toMatch(/^[0-9a-f-]{36}$/); + expect(commands[0]).not.toHaveProperty('successor'); + const history = page.getByTestId('equipment-replacement-history'); + await expect( + history.getByRole('link', { name: 'Open the successor equipment', exact: true }), + ).toHaveAttribute('href', `/organizations/${E2E_ORGANIZATION_ID}/equipments/${reserve.id}`); + await history.getByRole('link', { name: 'Open the successor equipment', exact: true }).click(); + await expect( + page + .getByTestId('equipment-replacement-history') + .getByRole('link', { name: 'Open the replaced equipment', exact: true }), + ).toHaveAttribute('href', `/organizations/${E2E_ORGANIZATION_ID}/equipments/${old.id}`); + }); + + test('replays the exact new-equipment operation after its committed response is lost', async ({ + page, + context, + baseURL, + }, testInfo) => { + await setDarkTheme(context, baseURL ?? 'http://localhost:4273'); + await page.setViewportSize({ width: 375, height: 812 }); + const api = new ApiMock(page); + await api.mockAuthenticatedSession(); + const old = { ...equipmentOutput(), name: 'Extinguisher EXT-01', assetCode: 'EXT-01' }; + const successorId = 'e2e-equipment-successor'; + await api.mockEquipmentDetail(E2E_ORGANIZATION_ID, old); + await api.mockEquipmentList(E2E_ORGANIZATION_ID, []); + const commands: ReplacementCommand[] = []; + let committedOperation: string | undefined; + let commits = 0; + await page.route(new RegExp(`${replacementPath}(\\?.*)?$`), async (route) => { + if (route.request().method() !== 'POST') return route.fallback(); + const command = route.request().postDataJSON() as ReplacementCommand; + commands.push(command); + if (!committedOperation) { + committedOperation = command.clientOperationId; + commits += 1; + await route.abort('failed'); + return; + } + expect(command.clientOperationId).toBe(committedOperation); + await receipt(route, command, successorId, true); + }); + await openReplacement(page); + const sheet = page.getByTestId('equipment-replacement-sheet'); + await sheet.getByRole('button', { name: 'New equipment', exact: true }).click(); + await sheet.getByTestId('equipment-create-type').click(); + await page.getByRole('option', { name: 'Fire extinguisher', exact: true }).click(); + await sheet.getByTestId('equipment-create-name').fill('Replacement extinguisher'); + await sheet.getByTestId('equipment-create-asset-code').fill('EXT-NEW'); + await expectNoHorizontalOverflow(page); + await expectNoInternalOverflow(sheet); + await page.screenshot({ + path: `${pagePolishScreenshotDir(testInfo.project.name)}/equipment-replacement-new-narrow-dark.png`, + animations: 'disabled', + }); + await sheet.getByTestId('equipment-create-submit').click(); + await expect(page.getByTestId('equipment-replacement-confirm-dialog')).toBeVisible(); + expect(commands).toEqual([]); + await page.getByTestId('equipment-replacement-final-confirm').click(); + await expect(sheet.getByText(/The response was lost/)).toBeVisible(); + await expect(sheet.getByTestId('equipment-create-submit')).toBeHidden(); + await expect(sheet.getByTestId('equipment-create-name')).toBeHidden(); + await expect(sheet.getByRole('button', { name: 'Retry', exact: true })).toBeVisible(); + expect(commands).toHaveLength(1); + expect(commits).toBe(1); + await page.screenshot({ + path: `${pagePolishScreenshotDir(testInfo.project.name)}/equipment-replacement-recovery-narrow-dark.png`, + animations: 'disabled', + }); + await sheet.getByRole('button', { name: 'Retry', exact: true }).click(); + await expect(sheet).toBeHidden(); + expect(commands).toHaveLength(2); + expect(commands[1]).toEqual(commands[0]); + expect(commands[0]?.successor).toMatchObject({ + type: 'fire_extinguisher', + name: 'Replacement extinguisher', + assetCode: 'EXT-NEW', + }); + expect(commands[0]?.successor).not.toHaveProperty('facility'); + expect(commands[0]?.successor).not.toHaveProperty('intervention'); + expect(commits).toBe(1); + await expect( + page + .getByTestId('equipment-replacement-history') + .getByRole('link', { name: 'Open the successor equipment', exact: true }), + ).toHaveAttribute('href', `/organizations/${E2E_ORGANIZATION_ID}/equipments/${successorId}`); + }); +}); diff --git a/tests/e2e/organization/equipments.spec.ts b/tests/e2e/organization/equipments.spec.ts index e6e3b98cc..98d3ae060 100644 --- a/tests/e2e/organization/equipments.spec.ts +++ b/tests/e2e/organization/equipments.spec.ts @@ -223,8 +223,10 @@ test.describe('Equipment detail', () => { await expect(equipments.decommissionAction).toBeVisible(); await page.keyboard.press('Escape'); - // Both status badges carry an icon alongside the label — never colour alone. - const statusTags = page.locator('app-equipment-status-tag'); + // Operational and control statuses carry an icon alongside their own labels. + const statusTags = page + .getByTestId('equipment-follow-up-axes') + .locator('app-equipment-status-tag'); await expect(statusTags).toHaveCount(2); await Promise.all( (await statusTags.all()).map(async (tag) => { diff --git a/tests/e2e/organization/facility-review-fixes.spec.ts b/tests/e2e/organization/facility-review-fixes.spec.ts index 382d3d8eb..414845f7e 100644 --- a/tests/e2e/organization/facility-review-fixes.spec.ts +++ b/tests/e2e/organization/facility-review-fixes.spec.ts @@ -65,7 +65,12 @@ interface ReviewScene { importedSelectionOutlines: ReviewObject[]; buildingGroup: ReviewObject | null; floorGroups: Map; - renderer: { info: { memory: { geometries: number; textures: number } } } | null; + renderer: { + info: { + memory: { geometries: number; textures: number }; + render: { lines: number; points: number }; + }; + } | null; controls: unknown; resizeObserver: unknown; renderRaf: number | null; @@ -496,6 +501,23 @@ test('picks and isolates GLB Line and Points primitives and releases their real await page.getByTestId('facility-3d-model-management').locator('summary').click(); const host = page.getByTestId('facility-3d-scene'); const canvas = host.locator('canvas'); + await expect + .poll( + () => + host.evaluate((element) => { + const scene = (window as unknown as ReviewWindow).ng.getComponent( + element.closest('app-facility-building-3d-scene') ?? element, + ); + const info = scene?.renderer?.info; + return { + geometriesAllocated: (info?.memory.geometries ?? 0) >= 2, + linesDrawn: (info?.render.lines ?? 0) > 0, + pointsDrawn: (info?.render.points ?? 0) > 0, + }; + }), + { timeout: 5_000, message: 'Both fixture primitives must reach the GPU before selection.' }, + ) + .toEqual({ geometriesAllocated: true, linesDrawn: true, pointsDrawn: true }); await canvas.click({ position: await primitiveCanvasPoint(page, 0) }); const link = page.getByTestId('facility-3d-bound-facility-link'); await expect(link).toHaveAttribute( @@ -536,6 +558,26 @@ test('picks and isolates GLB Line and Points primitives and releases their real 'href', `/organizations/${E2E_ORGANIZATION_ID}/facilities/e2e-upper-floor`, ); + await expect + .poll( + () => + host.evaluate((element) => { + const scene = (window as unknown as ReviewWindow).ng.getComponent( + element.closest('app-facility-building-3d-scene') ?? element, + ); + const info = scene?.renderer?.info; + return { + geometriesAllocated: (info?.memory.geometries ?? 0) >= 2, + selectedPointDrawn: (info?.render.points ?? 0) >= 2, + frameSettled: scene?.renderRaf === null, + }; + }), + { + message: + 'Reselected Points and their replacement highlight must reach the GPU before teardown.', + }, + ) + .toEqual({ geometriesAllocated: true, selectedPointDrawn: true, frameSettled: true }); const documentId = await host.evaluate((element) => { const browser = window as unknown as ReviewWindow; const scene = browser.ng.getComponent( diff --git a/tests/e2e/organization/interaction-mode.mobile.spec.ts b/tests/e2e/organization/interaction-mode.mobile.spec.ts index da47b2c9f..8c7add4ae 100644 --- a/tests/e2e/organization/interaction-mode.mobile.spec.ts +++ b/tests/e2e/organization/interaction-mode.mobile.spec.ts @@ -41,9 +41,9 @@ for (const theme of ['light', 'dark'] as const) { await expect(page.locator('html')).toHaveAttribute('data-interaction-mode', 'mobile'); await expect(page.locator('html')).toHaveAttribute('data-theme', theme); await expect(shell.navigation.getByRole('link')).toHaveText([ - 'Home', + 'Equipment park', 'Interventions', - 'Assets', + 'Home', 'Messages', 'More', ]); @@ -217,7 +217,7 @@ test('keeps every permitted secondary route in More and switches organizations t await secondOption.click(); await expect(page).toHaveURL(new RegExp(`/organizations/${second.id}(?:/interventions)?$`)); await expect(switcher).toHaveCount(0); - await expect(shell.navigation.getByRole('link')).toHaveText(['Home', 'Interventions', 'More']); + await expect(shell.navigation.getByRole('link')).toHaveText(['Interventions', 'Home', 'More']); await shell.navigation.getByRole('link', { name: 'More', exact: true }).click(); await expect(page.locator('#organization-more-switcher-trigger')).toContainText(second.name); await expect(shell.more.locator('#organization-more-administration')).toHaveCount(0); diff --git a/tests/e2e/organization/intervention-execution-result.mobile.spec.ts b/tests/e2e/organization/intervention-execution-result.mobile.spec.ts new file mode 100644 index 000000000..a85ac13b9 --- /dev/null +++ b/tests/e2e/organization/intervention-execution-result.mobile.spec.ts @@ -0,0 +1,23 @@ +import { expect, test } from '@playwright/test'; +import { collectConsoleErrors, expectNoHorizontalOverflow } from '../support/helpers/appearance'; +import { + recordConfirmedReplacementResult, + recordOfflineMaintenanceResult, +} from '../support/helpers/intervention-execution'; + +test('records an unsuccessful maintenance attempt with touch controls and preserves offline facts', async ({ + page, +}) => { + const errors = collectConsoleErrors(page); + await recordOfflineMaintenanceResult(page); + await expectNoHorizontalOverflow(page); + expect(errors).toEqual([]); +}); + +test('keeps a replacement draft while confirming its successor and replays it offline with touch controls', async ({ + page, +}) => { + const errors = collectConsoleErrors(page); + await recordConfirmedReplacementResult(page); + expect(errors).toEqual([]); +}); diff --git a/tests/e2e/organization/intervention-execution-result.spec.ts b/tests/e2e/organization/intervention-execution-result.spec.ts new file mode 100644 index 000000000..f5a8e24ce --- /dev/null +++ b/tests/e2e/organization/intervention-execution-result.spec.ts @@ -0,0 +1,37 @@ +import { expect, test } from '@playwright/test'; +import { + collectConsoleErrors, + expectNoHorizontalOverflow, + setDarkTheme, +} from '../support/helpers/appearance'; +import { + recordConfirmedReplacementResult, + recordOfflineMaintenanceResult, +} from '../support/helpers/intervention-execution'; + +for (const theme of ['light', 'dark'] as const) { + test(`records the actual maintenance attempt offline and replays it in ${theme} mode`, async ({ + page, + context, + baseURL, + }) => { + await page.setViewportSize({ width: 1280, height: 900 }); + if (theme === 'dark') await setDarkTheme(context, baseURL ?? 'http://localhost:4273'); + const errors = collectConsoleErrors(page); + await recordOfflineMaintenanceResult(page); + await expectNoHorizontalOverflow(page); + expect(errors).toEqual([]); + }); + + test(`confirms a real replacement successor and replays completion in ${theme} mode`, async ({ + page, + context, + baseURL, + }) => { + await page.setViewportSize({ width: 1280, height: 900 }); + if (theme === 'dark') await setDarkTheme(context, baseURL ?? 'http://localhost:4273'); + const errors = collectConsoleErrors(page); + await recordConfirmedReplacementResult(page); + expect(errors).toEqual([]); + }); +} diff --git a/tests/e2e/organization/intervention-inventory-offline.mobile.spec.ts b/tests/e2e/organization/intervention-inventory-offline.mobile.spec.ts new file mode 100644 index 000000000..8d6d40bfb --- /dev/null +++ b/tests/e2e/organization/intervention-inventory-offline.mobile.spec.ts @@ -0,0 +1,226 @@ +import { expect, test } from '@playwright/test'; +import { E2E_INTERVENTION_ID } from '../support/fixtures/intervention-fixtures'; +import { + collectConsoleErrors, + expectMinimumCssPixels, + expectNoHorizontalOverflow, + expectNoInternalOverflow, + setDarkTheme, +} from '../support/helpers/appearance'; +import { emulateMobilePlatform } from '../support/helpers/interaction-mode'; +import { + ACCOUNT_ID, + arrangeInventory, + openPreparedInventory, +} from '../support/helpers/intervention-inventory-scenario'; +import { readOutboxOperations, setAppOffline, setAppOnline } from '../support/helpers/offline'; +import { pagePolishScreenshotDir } from '../support/helpers/screenshot-dir'; + +test.beforeEach(async ({ context, browserName }) => { + await emulateMobilePlatform(context, browserName === 'webkit' ? 'ios' : 'android'); +}); + +test.describe('Physical stock declarations on an offline phone', () => { + for (const theme of ['light', 'dark'] as const) { + test(`retains the exact consumption UUID through an offline reload and shows reconciliation separately in ${theme} mode`, async ({ + page, + context, + baseURL, + }, testInfo) => { + const errors = collectConsoleErrors(page); + if (theme === 'dark') await setDarkTheme(context, baseURL ?? 'http://localhost:4273'); + const { path, mock } = await arrangeInventory(page); + await page.goto(path); + await expect(page.locator('html')).toHaveAttribute('data-interaction-mode', 'mobile'); + expect(await page.evaluate(() => matchMedia('(pointer: coarse)').matches)).toBe(true); + const section = await openPreparedInventory(page); + await expect(page.getByTestId('intervention-maintenance-cost-link')).toHaveCount(0); + await setAppOffline(page); + + const part = section.getByRole('combobox', { name: 'Part', exact: true }); + await part.tap(); + await part.fill('SEAL'); + const partOption = page.getByRole('option', { + name: 'Valve seal · SEAL · piece', + exact: true, + }); + await expect(partOption).toBeVisible(); + await page.screenshot({ + path: `${pagePolishScreenshotDir(testInfo.project.name)}/intervention-stock-phone-part-picker-${theme}.png`, + animations: 'disabled', + }); + await partOption.tap(); + const warehouse = section.getByRole('combobox', { name: 'Warehouse', exact: true }); + await warehouse.tap(); + await warehouse.fill('VAN'); + await page.getByRole('option', { name: 'Service van · VAN', exact: true }).tap(); + const quantity = section.getByLabel('Quantity used', { exact: true }); + await expect(quantity).toHaveAttribute('inputmode', 'decimal'); + await quantity.tap(); + await quantity.fill('0.25'); + const submit = section.getByRole('button', { name: 'Declare consumption', exact: true }); + await submit.scrollIntoViewIfNeeded(); + const submitBox = await submit.boundingBox(); + if (!submitBox) throw new Error('The consumption action must be visible on the phone.'); + expectMinimumCssPixels(submitBox.height, 44); + await expectNoHorizontalOverflow(page); + await expectNoInternalOverflow(section); + await submit.tap(); + + await expect + .poll(async () => (await readOutboxOperations(page, E2E_INTERVENTION_ID)).length) + .toBe(1); + const [queued] = await readOutboxOperations(page, E2E_INTERVENTION_ID); + if (!queued) + throw new Error('The physical declaration must be safely persisted before reset.'); + expect(queued.type).toBe('inventory-consumption.declare'); + expect(queued.payload['clientOperationId']).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i, + ); + expect(queued.id).toBe(`inventory:${queued.payload['clientOperationId']}`); + expect(queued.payload).toMatchObject({ + partId: mock.parts[0]?.id, + warehouseId: mock.warehouses[0]?.id, + quantity: '0.250000', + interventionId: E2E_INTERVENTION_ID, + actorId: ACCOUNT_ID, + }); + await expect( + section.getByText('Queued locally — awaiting synchronization', { exact: true }), + ).toBeVisible(); + await expect(quantity).toHaveValue(''); + expect(mock.writes).toEqual([]); + + await page.addInitScript(() => { + Object.defineProperty(navigator, 'onLine', { configurable: true, get: () => false }); + }); + await page.reload(); + await page.getByTestId('intervention-inventory-toggle').tap(); + await expect( + section.getByText('Queued locally — awaiting synchronization', { exact: true }), + ).toBeVisible(); + const persisted = await readOutboxOperations(page, E2E_INTERVENTION_ID); + expect(persisted).toHaveLength(1); + expect(persisted[0]?.payload).toEqual(queued.payload); + expect(mock.writes).toEqual([]); + await expect(page.getByTestId('intervention-maintenance-cost-link')).toHaveCount(0); + + await setAppOnline(page); + await expect.poll(() => mock.writes.length).toBe(1); + expect(mock.writes[0]).toMatchObject({ + clientOperationId: queued.payload['clientOperationId'], + partId: queued.payload['partId'], + warehouseId: queued.payload['warehouseId'], + quantity: '0.250000', + interventionId: E2E_INTERVENTION_ID, + occurredAt: queued.payload['occurredAt'], + }); + expect(mock.writes[0]).not.toHaveProperty('actorId'); + expect(mock.writes[0]).not.toHaveProperty('clientId'); + await expect + .poll(async () => (await readOutboxOperations(page, E2E_INTERVENTION_ID)).length) + .toBe(0); + await expect( + section.getByText('Queued locally — awaiting synchronization', { exact: true }), + ).toHaveCount(0); + await expect( + section.getByText('Received — reconciliation needed', { exact: true }), + ).toBeVisible(); + await expect(section).toContainText('0.250000'); + expect(mock.receipts).toHaveLength(1); + await expectNoHorizontalOverflow(page); + await expectNoInternalOverflow(section); + await section + .getByText('Received — reconciliation needed', { exact: true }) + .scrollIntoViewIfNeeded(); + await page.screenshot({ + path: `${pagePolishScreenshotDir(testInfo.project.name)}/intervention-stock-phone-reconciliation-${theme}.png`, + animations: 'disabled', + }); + expect(errors).toEqual([]); + }); + } + + test('replays a committed consumption after a lost response and offline reload with its original UUID and physical timestamp', async ({ + page, + }, testInfo) => { + const errors = collectConsoleErrors(page); + const { path, mock } = await arrangeInventory(page); + mock.loseNextCommittedResponse = true; + await page.goto(path); + const section = await openPreparedInventory(page); + await setAppOffline(page); + const part = section.getByRole('combobox', { name: 'Part', exact: true }); + await part.tap(); + await part.fill('SEAL'); + await page.getByRole('option', { name: 'Valve seal · SEAL · piece', exact: true }).tap(); + const warehouse = section.getByRole('combobox', { name: 'Warehouse', exact: true }); + await warehouse.tap(); + await warehouse.fill('VAN'); + await page.getByRole('option', { name: 'Service van · VAN', exact: true }).tap(); + await section.getByLabel('Quantity used', { exact: true }).fill('0.000001'); + await section.getByRole('button', { name: 'Declare consumption', exact: true }).tap(); + await expect + .poll(async () => (await readOutboxOperations(page, E2E_INTERVENTION_ID)).length) + .toBe(1); + const [original] = await readOutboxOperations(page, E2E_INTERVENTION_ID); + if (!original) throw new Error('The physical declaration must already be durable.'); + const originalPayload = original.payload; + expect(originalPayload['quantity']).toBe('0.000001'); + expect(originalPayload['actorId']).toBe(ACCOUNT_ID); + + await setAppOnline(page); + await expect.poll(() => mock.writes.length).toBe(1); + await expect.poll(() => mock.receipts.length).toBe(1); + await setAppOffline(page); + const uncertain = await readOutboxOperations(page, E2E_INTERVENTION_ID); + expect(uncertain).toHaveLength(1); + expect(uncertain[0]?.payload).toEqual(originalPayload); + await page.addInitScript(() => { + Object.defineProperty(navigator, 'onLine', { configurable: true, get: () => false }); + }); + await page.reload(); + await page.getByTestId('intervention-inventory-toggle').tap(); + await expect( + section.getByText(/^Queued locally — (awaiting synchronization|retry needed)$/), + ).toBeVisible(); + const restored = await readOutboxOperations(page, E2E_INTERVENTION_ID); + expect(restored).toHaveLength(1); + expect(restored[0]?.id).toBe(original.id); + expect(restored[0]?.payload).toEqual(originalPayload); + expect(mock.writes).toHaveLength(1); + expect(mock.receipts).toHaveLength(1); + + await setAppOnline(page); + await expect.poll(() => mock.writes.length).toBe(2); + await expect + .poll(async () => (await readOutboxOperations(page, E2E_INTERVENTION_ID)).length) + .toBe(0); + expect(mock.writes[1]).toEqual(mock.writes[0]); + expect(mock.writes[1]).toMatchObject({ + clientOperationId: originalPayload['clientOperationId'], + quantity: '0.000001', + occurredAt: originalPayload['occurredAt'], + }); + expect(mock.writes[1]).not.toHaveProperty('actorId'); + expect(mock.writes[1]).not.toHaveProperty('clientId'); + expect(mock.receipts).toHaveLength(1); + await expect( + section.getByText('Received — reconciliation needed', { exact: true }), + ).toHaveCount(1); + await expect(section).toContainText('0.000001'); + await expectNoHorizontalOverflow(page); + await expectNoInternalOverflow(section); + await section + .getByText('Received — reconciliation needed', { exact: true }) + .scrollIntoViewIfNeeded(); + await page.screenshot({ + path: `${pagePolishScreenshotDir(testInfo.project.name)}/intervention-stock-phone-response-replay.png`, + animations: 'disabled', + }); + expect( + errors.filter((error) => !error.includes('net::ERR_FAILED')), + errors.join('\n'), + ).toEqual([]); + }); +}); diff --git a/tests/e2e/organization/intervention-inventory-offline.spec.ts b/tests/e2e/organization/intervention-inventory-offline.spec.ts new file mode 100644 index 000000000..b6fe2ebb0 --- /dev/null +++ b/tests/e2e/organization/intervention-inventory-offline.spec.ts @@ -0,0 +1,305 @@ +import { expect, test } from '@playwright/test'; +import { E2E_INTERVENTION_ID } from '../support/fixtures/intervention-fixtures'; +import { + collectConsoleErrors, + expectNoHorizontalOverflow, + expectNoInternalOverflow, + setDarkTheme, +} from '../support/helpers/appearance'; +import { + ACCOUNT_ID, + arrangeInventory, + openPreparedInventory, + fillConsumption, + captureInventory, +} from '../support/helpers/intervention-inventory-scenario'; +import { + readOutboxOperations, + readStore, + setAppOffline, + setAppOnline, +} from '../support/helpers/offline'; + +test.describe('Parts used in an offline intervention dossier', () => { + for (const scenario of [ + { width: 1280, theme: 'light' }, + { width: 375, theme: 'light' }, + { width: 375, theme: 'dark' }, + ] as const) { + test(`retains a decimal consumption offline and separates synchronization from stock reconciliation at ${scenario.width}px in ${scenario.theme} mode`, async ({ + page, + context, + baseURL, + }) => { + await page.setViewportSize({ width: scenario.width, height: 900 }); + if (scenario.theme === 'dark') + await setDarkTheme(context, baseURL ?? 'http://localhost:4273'); + const errors = collectConsoleErrors(page); + const { path, mock } = await arrangeInventory(page); + await page.goto(path); + const section = await openPreparedInventory(page); + const inventoryMetadata = (await readStore(page, 'metadata')).filter( + (value) => + (value as Record)['version'] === 1 && + (value as Record)['catalogComplete'] === true, + ); + expect(inventoryMetadata).toHaveLength(1); + expect(JSON.stringify(inventoryMetadata)).not.toMatch( + /valuation|unitCost|totalValue|currency/, + ); + await expect(page.getByTestId('intervention-maintenance-cost-link')).toHaveCount(0); + await setAppOffline(page); + await fillConsumption(page, section); + await expectNoHorizontalOverflow(page); + await expectNoInternalOverflow(section); + await captureInventory(page, section, 'form'); + + await page.getByTestId('intervention-inventory-toggle').click(); + await expect(section.getByLabel('Quantity used', { exact: true })).toBeHidden(); + await page.getByTestId('intervention-inventory-toggle').click(); + await expect(section.getByRole('combobox', { name: 'Part', exact: true })).toHaveValue( + 'Valve seal', + ); + await expect(section.getByRole('combobox', { name: 'Warehouse', exact: true })).toHaveValue( + 'Service van', + ); + await expect(section.getByLabel('Quantity used', { exact: true })).toHaveValue('0.25'); + await section.getByRole('button', { name: 'Declare consumption', exact: true }).click(); + + await expect + .poll(async () => (await readOutboxOperations(page, E2E_INTERVENTION_ID)).length) + .toBe(1); + const [queued] = await readOutboxOperations(page, E2E_INTERVENTION_ID); + if (!queued) throw new Error('The physical declaration must be durably queued.'); + expect(queued.type).toBe('inventory-consumption.declare'); + expect(queued.payload['clientOperationId']).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i, + ); + expect(queued.id).toBe(`inventory:${queued.payload['clientOperationId']}`); + expect(queued.payload).toMatchObject({ + partId: mock.parts[0]?.id, + warehouseId: mock.warehouses[0]?.id, + quantity: '0.250000', + interventionId: E2E_INTERVENTION_ID, + actorId: ACCOUNT_ID, + }); + await expect( + section.getByText('Queued locally — awaiting synchronization', { exact: true }), + ).toBeVisible(); + await expect(section.getByLabel('Quantity used', { exact: true })).toHaveValue(''); + expect(mock.writes).toEqual([]); + await captureInventory(page, section, 'queued'); + + await page.addInitScript(() => { + Object.defineProperty(navigator, 'onLine', { configurable: true, get: () => false }); + }); + await page.reload(); + await page.getByTestId('intervention-inventory-toggle').click(); + await expect( + section.getByText('Queued locally — awaiting synchronization', { exact: true }), + ).toBeVisible(); + const persisted = await readOutboxOperations(page, E2E_INTERVENTION_ID); + expect(persisted).toHaveLength(1); + expect(persisted[0]?.payload).toEqual(queued.payload); + expect(mock.writes).toEqual([]); + await expect(page.getByTestId('intervention-maintenance-cost-link')).toHaveCount(0); + + await setAppOnline(page); + await expect.poll(() => mock.writes.length).toBe(1); + expect(mock.writes[0]).toMatchObject({ + clientOperationId: queued.payload['clientOperationId'], + partId: queued.payload['partId'], + warehouseId: queued.payload['warehouseId'], + quantity: '0.250000', + interventionId: E2E_INTERVENTION_ID, + occurredAt: queued.payload['occurredAt'], + }); + expect(mock.writes[0]).not.toHaveProperty('actorId'); + expect(mock.writes[0]).not.toHaveProperty('clientId'); + await expect + .poll(async () => (await readOutboxOperations(page, E2E_INTERVENTION_ID)).length) + .toBe(0); + await expect( + section.getByText('Queued locally — awaiting synchronization', { exact: true }), + ).toHaveCount(0); + await expect( + section.getByText('Received — reconciliation needed', { exact: true }), + ).toBeVisible(); + await expect(section).toContainText('0.250000'); + await expect + .poll(async () => + (await readStore(page, 'metadata')).some((entry) => { + const saved = entry as { + readonly interventionId?: string; + readonly declarations?: readonly { + readonly status: string; + readonly quantity: string; + }[]; + }; + return ( + saved.interventionId === E2E_INTERVENTION_ID && + saved.declarations?.some( + (fact) => fact.status === 'received_pending' && fact.quantity === '0.250000', + ) === true + ); + }), + ) + .toBe(true); + await expectNoHorizontalOverflow(page); + await expectNoInternalOverflow(section); + await captureInventory(page, section, 'reconciliation'); + expect(errors).toEqual([]); + }); + } + + test('keeps a consumption after a device quota failure and cancels departure before retrying the same fact', async ({ + page, + }) => { + await page.setViewportSize({ width: 1280, height: 900 }); + const { path, mock } = await arrangeInventory(page); + await page.goto(path); + const section = await openPreparedInventory(page); + await setAppOffline(page); + await page.evaluate(() => { + const fault = { enabled: true, attempts: [] as Record[] }; + Object.assign(window, { inventoryOutboxTestFault: fault }); + const original = IDBObjectStore.prototype.put; + IDBObjectStore.prototype.put = function ( + value: unknown, + key?: IDBValidKey, + ): IDBRequest { + const operation = value as { + readonly type?: string; + readonly payload?: Record; + }; + if (this.name === 'outbox' && operation.type === 'inventory-consumption.declare') { + fault.attempts.push({ ...operation.payload }); + if (fault.enabled) + throw new DOMException('Device storage is full.', 'QuotaExceededError'); + } + return key === undefined ? original.call(this, value) : original.call(this, value, key); + }; + }); + await fillConsumption(page, section); + await section.getByRole('button', { name: 'Declare consumption', exact: true }).click(); + await expect(page.getByTestId('intervention-inventory-not-saved')).toBeVisible(); + await expect(section.getByLabel('Quantity used', { exact: true })).toHaveValue('0.25'); + await expect(section.getByRole('button', { name: 'Retry', exact: true })).toBeVisible(); + expect(await readOutboxOperations(page, E2E_INTERVENTION_ID)).toEqual([]); + + await page + .getByRole('link', { name: 'Interventions', exact: true }) + .filter({ visible: true }) + .first() + .click(); + const warning = page.getByTestId('unsaved-changes-dialog'); + await expect(warning).toBeVisible(); + await warning.getByRole('button', { name: 'Cancel', exact: true }).click(); + await expect(warning).toHaveCount(0); + await expect(page).toHaveURL(new RegExp(`${path}$`)); + await expect(page.getByTestId('intervention-inventory-not-saved')).toBeVisible(); + + await page.evaluate(() => { + const state = window as Window & { inventoryOutboxTestFault?: { enabled: boolean } }; + if (!state.inventoryOutboxTestFault) throw new Error('The storage fault must be installed.'); + state.inventoryOutboxTestFault.enabled = false; + }); + await section.getByRole('button', { name: 'Retry', exact: true }).click(); + await expect + .poll(async () => (await readOutboxOperations(page, E2E_INTERVENTION_ID)).length) + .toBe(1); + await expect(page.getByTestId('intervention-inventory-not-saved')).toHaveCount(0); + await expect( + section.getByText('Queued locally — awaiting synchronization', { exact: true }), + ).toBeVisible(); + const attempts = await page.evaluate(() => { + const state = window as Window & { + inventoryOutboxTestFault?: { attempts: readonly Record[] }; + }; + if (!state.inventoryOutboxTestFault) throw new Error('The storage fault must be installed.'); + return state.inventoryOutboxTestFault.attempts; + }); + expect(attempts).toHaveLength(2); + expect(attempts[1]).toEqual(attempts[0]); + expect(attempts[0]?.['quantity']).toBe('0.250000'); + expect(mock.writes).toEqual([]); + await setAppOnline(page); + await expect.poll(() => mock.writes.length).toBe(1); + expect(mock.writes[0]?.clientOperationId).toBe(attempts[0]?.['clientOperationId']); + expect(mock.writes[0]?.occurredAt).toBe(attempts[0]?.['occurredAt']); + await expect + .poll(async () => (await readOutboxOperations(page, E2E_INTERVENTION_ID)).length) + .toBe(0); + await expect( + section.getByText('Received — reconciliation needed', { exact: true }), + ).toBeVisible(); + await captureInventory(page, section, 'quota-retry'); + }); + + test('shows a late retained stock fact in a published dossier without financial values or consumption rights', async ({ + page, + }) => { + const { path, mock } = await arrangeInventory(page, { status: 'published', canConsume: false }); + const part = mock.parts[0], + warehouse = mock.warehouses[0]; + if (!part || !warehouse) + throw new Error('The late physical fact requires real stock references.'); + mock.receipts.push({ + '@id': `${mock.parts[0]?.['@id']?.split('/inventory-parts/')[0]}/inventory-consumptions/late-receipt`, + '@type': 'InventoryConsumption', + id: 'late-receipt', + partId: part.id, + warehouseId: warehouse.id, + quantity: '0.250000', + interventionId: E2E_INTERVENTION_ID, + actorId: ACCOUNT_ID, + occurredAt: new Date().toISOString(), + status: 'received_pending', + reason: 'insufficient_stock', + late: true, + replayed: false, + }); + await page.goto(path); + await page.getByTestId('intervention-inventory-toggle').click(); + const section = page.getByTestId('intervention-inventory-section'); + const lateNotice = section.getByText( + 'Recorded after publication; the published dossier remains unchanged.', + { exact: true }, + ); + await expect(lateNotice).toBeVisible(); + await expect( + section.getByText('Received — reconciliation needed', { exact: true }), + ).toBeVisible(); + await expect(section.getByLabel('Quantity used', { exact: true })).toBeHidden(); + await expect( + section.getByRole('button', { name: 'Declare consumption', exact: true }), + ).toBeHidden(); + await expect(page.getByTestId('intervention-maintenance-cost-link')).toHaveCount(0); + expect(mock.writes).toEqual([]); + await expect + .poll(async () => + (await readStore(page, 'metadata')).some((value) => { + const saved = value as { + readonly version?: number; + readonly declarations?: readonly { readonly id: string; readonly late: boolean }[]; + }; + return ( + saved.version === 1 && + saved.declarations?.some((item) => item.id === 'late-receipt' && item.late) === true + ); + }), + ) + .toBe(true); + const saved = (await readStore(page, 'metadata')).filter( + (value) => (value as Record)['version'] === 1, + ); + expect(JSON.stringify(saved)).not.toMatch(/valuation|unitCost|totalValue|currency/); + await lateNotice.scrollIntoViewIfNeeded(); + await expectNoHorizontalOverflow(page); + await expectNoInternalOverflow(section); + await page.screenshot({ + path: 'tests/e2e/artifacts/intervention-inventory/published-late-quantity-only.png', + animations: 'disabled', + }); + }); +}); diff --git a/tests/e2e/organization/inventory.mobile.spec.ts b/tests/e2e/organization/inventory.mobile.spec.ts new file mode 100644 index 000000000..29a9e333a --- /dev/null +++ b/tests/e2e/organization/inventory.mobile.spec.ts @@ -0,0 +1,170 @@ +import { expect, test, type Locator } from '@playwright/test'; +import { E2E_ORGANIZATION_ID } from '../support/fixtures/api-fixtures'; +import { + collectConsoleErrors, + expectMinimumCssPixels, + expectNoHorizontalOverflow, + expectNoInternalOverflow, + setDarkTheme, +} from '../support/helpers/appearance'; +import { emulateMobilePlatform } from '../support/helpers/interaction-mode'; +import { + consumptionId, + fixtures, + json, + mockInventory, + movementId, + type PhysicalInput, +} from '../support/helpers/inventory-scenario'; +import { pagePolishScreenshotDir } from '../support/helpers/screenshot-dir'; +import { ApiMock } from '../support/mocks/api-mock'; + +/** Checks the real critical action before tapping; phone actions require 44px targets. */ +async function tapTouchAction(action: Locator): Promise { + await action.scrollIntoViewIfNeeded(); + const bounds = await action.boundingBox(); + if (!bounds) throw new Error('The stock action must be visible on the phone.'); + expectMinimumCssPixels(bounds.height, 44); + await action.tap(); +} + +test.beforeEach(async ({ context, browserName }) => { + await emulateMobilePlatform(context, browserName === 'webkit' ? 'ios' : 'android'); +}); + +test.describe('Inventory with native phone interactions', () => { + test('reconciles a full shortage declaration and records a fractional return using touch controls', async ({ + page, + }, testInfo) => { + const errors = collectConsoleErrors(page); + const api = new ApiMock(page); + await api.mockAuthenticatedSession(); + await api.mockOrganizationAccess(E2E_ORGANIZATION_ID, { + permissions: [ + 'organization.inventory.read', + 'organization.inventory.manage', + 'organization.inventory.consume', + 'organization.interventions.execute', + ], + }); + const data = fixtures(); + const commands: unknown[] = []; + let reconciliations = 0; + await mockInventory(page, data, async (route, resource, id) => { + const input = route.request().postDataJSON() as PhysicalInput; + commands.push(input); + if (resource === 'inventory-consumptions' && id === consumptionId) { + reconciliations++; + if (reconciliations === 2) { + data.consumption.status = 'confirmed'; + data.consumption.reason = undefined; + } + await json(route, 200, data.consumption); + return; + } + if (resource === 'inventory-returns') { + expect(input.consumptionId).toBe(consumptionId); + data.movement = { + ...data.movement, + kind: 'return', + quantity: input.quantity, + reason: input.reason, + }; + await json(route, 201, data.movement); + return; + } + throw new Error('Unexpected mobile inventory mutation'); + }); + + await page.goto(`/organizations/${E2E_ORGANIZATION_ID}/inventory/consumptions`); + await expect(page.locator('html')).toHaveAttribute('data-interaction-mode', 'mobile'); + expect(await page.evaluate(() => matchMedia('(pointer: coarse)').matches)).toBe(true); + expect(await page.evaluate(() => navigator.maxTouchPoints)).toBeGreaterThan(0); + const row = page.locator(`[data-inventory-id="${consumptionId}"]`); + await expect(row).toContainText('Received — to reconcile'); + await expect(row).toContainText('3.000000'); + await tapTouchAction(row.getByRole('button', { name: 'Reconcile stock', exact: true })); + await expect.poll(() => reconciliations).toBe(1); + await expect(row).toContainText('Received — to reconcile'); + await expect(row).toContainText('3.000000'); + await expect(row.getByRole('button', { name: 'Record return', exact: true })).toHaveCount(0); + await expectNoHorizontalOverflow(page); + await expectNoInternalOverflow(row); + + await tapTouchAction(row.getByRole('button', { name: 'Reconcile stock', exact: true })); + await expect(row).toContainText('Confirmed'); + await tapTouchAction(row.getByRole('button', { name: 'Record return', exact: true })); + const editor = page.getByTestId('inventory-editor-sheet'); + const quantity = editor.getByLabel('Quantity', { exact: true }); + await expect(quantity).toHaveAttribute('inputmode', 'decimal'); + await quantity.tap(); + await quantity.fill('0.000001'); + await editor.getByLabel('Reason', { exact: true }).fill('Unused seal returned after repair'); + await expectNoInternalOverflow(editor); + await page.screenshot({ + path: `${pagePolishScreenshotDir(testInfo.project.name)}/inventory-phone-return-editor-light.png`, + animations: 'disabled', + }); + await tapTouchAction(editor.getByRole('button', { name: 'Record movement', exact: true })); + await expect(editor).toBeHidden(); + expect(commands).toEqual([ + {}, + {}, + expect.objectContaining({ + consumptionId, + quantity: '0.000001', + reason: 'Unused seal returned after repair', + clientOperationId: expect.stringMatching(/^[0-9a-f-]{36}$/), + }), + ]); + await page.getByRole('link', { name: 'Movement history', exact: true }).tap(); + const history = page.locator(`[data-inventory-id="${movementId}"]`); + await expect(history).toContainText('Return'); + await expect(history).toContainText('0.000001'); + await expect(history).toContainText('Unused seal returned after repair'); + await expectNoHorizontalOverflow(page); + await expectNoInternalOverflow(history); + await page.screenshot({ + path: `${pagePolishScreenshotDir(testInfo.project.name)}/inventory-phone-return-history-light.png`, + animations: 'disabled', + }); + expect(errors).toEqual([]); + }); + + test('shows quantitative balances without exposing finance or mutation actions to a phone reader', async ({ + page, + context, + baseURL, + }, testInfo) => { + const errors = collectConsoleErrors(page); + await setDarkTheme(context, baseURL ?? 'http://localhost:4273'); + const api = new ApiMock(page); + await api.mockAuthenticatedSession(); + await api.mockOrganizationAccess(E2E_ORGANIZATION_ID, { + permissions: ['organization.inventory.read'], + }); + await mockInventory(page, fixtures(), async () => { + throw new Error('A stock reader must never mutate inventory'); + }); + await page.goto(`/organizations/${E2E_ORGANIZATION_ID}/inventory/balances`); + await expect(page.locator('html')).toHaveAttribute('data-interaction-mode', 'mobile'); + await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark'); + const balance = page.locator('[data-inventory-id="balance-1"]'); + await expect(balance).toContainText('1.000000'); + await expect(page.locator('app-inventory-dataview')).not.toContainText('9123'); + await expect( + page.getByRole('button', { name: 'Record stock correction', exact: true }), + ).toHaveCount(0); + await expect( + page.getByRole('button', { name: 'New part or consumable', exact: true }), + ).toHaveCount(0); + await expect(page.getByRole('link', { name: 'Economic pilotage', exact: true })).toHaveCount(0); + await expectNoHorizontalOverflow(page); + await expectNoInternalOverflow(balance); + await page.screenshot({ + path: `${pagePolishScreenshotDir(testInfo.project.name)}/inventory-phone-quantity-reader-dark.png`, + animations: 'disabled', + }); + expect(errors).toEqual([]); + }); +}); diff --git a/tests/e2e/organization/inventory.spec.ts b/tests/e2e/organization/inventory.spec.ts new file mode 100644 index 000000000..81e9e8c51 --- /dev/null +++ b/tests/e2e/organization/inventory.spec.ts @@ -0,0 +1,276 @@ +import { expect, test } from '@playwright/test'; +import { E2E_ORGANIZATION_ID } from '../support/fixtures/api-fixtures'; +import { + collectConsoleErrors, + expectNoHorizontalOverflow, + expectNoInternalOverflow, + setDarkTheme, +} from '../support/helpers/appearance'; +import { pagePolishScreenshotDir } from '../support/helpers/screenshot-dir'; +import { ApiMock } from '../support/mocks/api-mock'; + +import { + partId, + warehouseId, + consumptionId, + movementId, + fixtures, + json, + mockInventory, + type Part, + type PhysicalInput, + orgPath, +} from '../support/helpers/inventory-scenario'; + +test.describe('Inventory', () => { + test('creates a quantitative consumable and archives then restores its permanent reference', async ({ + page, + }, testInfo) => { + const api = new ApiMock(page); + await api.mockAuthenticatedSession(); + const data = fixtures(), + commands: unknown[] = []; + await mockInventory(page, data, async (route, resource, id) => { + const input = route.request().postDataJSON() as Partial; + commands.push(input); + if (resource !== 'inventory-parts') throw new Error('Unexpected inventory mutation'); + if (route.request().method() === 'POST') { + const created: Part = { + '@id': orgPath + '/inventory-parts/new-part', + '@type': 'InventoryPart', + id: 'new-part', + code: input.code ?? '', + label: input.label ?? '', + unit: input.unit ?? '', + kind: input.kind ?? 'part', + archived: false, + }; + data.parts.push(created); + await json(route, 201, created); + return; + } + const entry = data.parts.find((part) => part.id === id); + if (!entry) throw new Error('Missing reference'); + Object.assign(entry, input); + await json(route, 200, entry); + }); + await page.goto('/organizations/' + E2E_ORGANIZATION_ID + '/inventory/parts'); + await page.getByRole('button', { name: 'New part or consumable', exact: true }).click(); + const editor = page.getByTestId('inventory-editor-sheet'); + await editor.locator('#inventory-reference-code').fill('CLEANER'); + await editor.locator('#inventory-reference-name').fill('Cleaning solution'); + await editor.locator('#inventory-reference-unit').fill('litre'); + await editor.getByRole('button', { name: 'Consumable', exact: true }).click(); + await editor.getByRole('button', { name: 'Save', exact: true }).click(); + await expect(editor).toBeHidden(); + const row = page.locator('[data-inventory-id="new-part"]'); + await expect(row).toContainText('Cleaning solution'); + await expect(row).toContainText('Consumable'); + await row.getByRole('button', { name: 'Edit', exact: true }).click(); + await expect(editor.locator('#inventory-reference-code')).toBeDisabled(); + await expect(editor.getByRole('button', { name: 'Consumable', exact: true })).toBeDisabled(); + await editor.getByRole('button', { name: 'Cancel', exact: true }).click(); + await row.getByRole('button', { name: 'Archive', exact: true }).click(); + await expect(row).toHaveCount(0); + await page.getByRole('button', { name: 'Archived', exact: true }).click(); + await expect(row).toBeVisible(); + await row.getByRole('button', { name: 'Restore', exact: true }).click(); + await expect(row).toHaveCount(0); + await page.getByRole('button', { name: 'Active', exact: true }).click(); + await expect(row).toBeVisible(); + expect(commands).toEqual([ + { code: 'CLEANER', label: 'Cleaning solution', unit: 'litre', kind: 'consumable' }, + { archived: true }, + { archived: false }, + ]); + await expectNoHorizontalOverflow(page); + await page.screenshot({ + path: pagePolishScreenshotDir(testInfo.project.name) + '/inventory-references-light.png', + animations: 'disabled', + }); + }); + + test('keeps a shortage declaration intact until a full reconciliation and records a motivated return', async ({ + page, + }, testInfo) => { + const api = new ApiMock(page); + await api.mockAuthenticatedSession(); + const data = fixtures(), + commands: unknown[] = []; + let attempts = 0; + await mockInventory(page, data, async (route, resource, id) => { + const input = route.request().postDataJSON() as PhysicalInput; + commands.push(input); + if (resource === 'inventory-consumptions' && id === consumptionId) { + attempts++; + if (attempts === 2) { + data.consumption.status = 'confirmed'; + data.consumption.reason = undefined; + } + await json(route, 200, data.consumption); + return; + } + if (resource === 'inventory-returns') { + expect(input.consumptionId).toBe(consumptionId); + data.movement = { + ...data.movement, + kind: 'return', + quantity: input.quantity, + reason: input.reason, + }; + await json(route, 201, data.movement); + return; + } + throw new Error('Unexpected inventory mutation'); + }); + await page.goto('/organizations/' + E2E_ORGANIZATION_ID + '/inventory/consumptions'); + const row = page.locator('[data-inventory-id="' + consumptionId + '"]'); + await expect(row).toContainText('Received — to reconcile'); + await expect(row).toContainText('3.000000'); + await row.getByRole('button', { name: 'Reconcile stock', exact: true }).click(); + await expect.poll(() => attempts).toBe(1); + await expect(row).toContainText('Received — to reconcile'); + await expect(row).toContainText('3.000000'); + await expect(row.getByRole('button', { name: 'Record return', exact: true })).toHaveCount(0); + await row.getByRole('button', { name: 'Reconcile stock', exact: true }).click(); + await expect(row).toContainText('Confirmed'); + await row.getByRole('button', { name: 'Record return', exact: true }).click(); + const editor = page.getByTestId('inventory-editor-sheet'); + await editor.locator('#inventory-movement-quantity').fill('1.25'); + await editor + .locator('#inventory-movement-reason') + .fill('Unused part returned to the service van'); + await editor.getByRole('button', { name: 'Record movement', exact: true }).click(); + await expect(editor).toBeHidden(); + expect(commands[0]).toEqual({}); + expect(commands[1]).toEqual({}); + expect(commands[2]).toEqual( + expect.objectContaining({ + consumptionId, + quantity: '1.250000', + reason: 'Unused part returned to the service van', + clientOperationId: expect.stringMatching(/^[0-9a-f-]{36}$/), + }), + ); + await page.getByRole('link', { name: 'Movement history', exact: true }).click(); + const history = page.locator('[data-inventory-id="' + movementId + '"]'); + await expect(history).toContainText('Return'); + await expect(history).toContainText('1.250000'); + await expect(history).toContainText('Unused part returned to the service van'); + await page.screenshot({ + path: pagePolishScreenshotDir(testInfo.project.name) + '/inventory-return-history-light.png', + animations: 'disabled', + }); + }); + + test('recovers a committed correction after a lost response and page reload on a narrow dark viewport', async ({ + page, + context, + baseURL, + }, testInfo) => { + const errors = collectConsoleErrors(page); + await setDarkTheme(context, baseURL ?? 'http://localhost:4273'); + await page.setViewportSize({ width: 375, height: 812 }); + const api = new ApiMock(page); + await api.mockAuthenticatedSession(); + const data = fixtures(), + received: PhysicalInput[] = []; + let commits = 0; + await mockInventory(page, data, async (route, resource) => { + if (resource !== 'inventory-corrections') throw new Error('Unexpected inventory mutation'); + const input = route.request().postDataJSON() as PhysicalInput; + received.push(input); + if (received.length === 1) { + commits++; + await route.abort('failed'); + return; + } + expect(input).toEqual(received[0]); + await json(route, 200, { + ...data.movement, + quantity: input.quantity, + reason: input.reason, + replayed: true, + }); + }); + await page.goto('/organizations/' + E2E_ORGANIZATION_ID + '/inventory/balances'); + await expect(page.locator('app-inventory-dataview')).not.toContainText('9123'); + await page.getByRole('button', { name: 'Record stock correction', exact: true }).click(); + const editor = page.getByTestId('inventory-editor-sheet'); + await editor.locator('#inventory-correction-part').click(); + await editor.locator('#inventory-correction-part').fill('Valve'); + await expect(page.getByRole('option', { name: /Valve seal/ })).toBeVisible(); + await page.getByRole('option', { name: /Valve seal/ }).click(); + await editor.locator('#inventory-correction-warehouse').fill('Service'); + await page.getByRole('option', { name: /Service van/ }).click(); + await editor.locator('#inventory-movement-quantity').fill('0.000001'); + await editor + .locator('#inventory-movement-reason') + .fill('Physical stock count after a replacement'); + await editor.locator('#inventory-movement-quantity').focus(); + await page.keyboard.press('Tab'); + await expect(editor.locator('#inventory-movement-reason')).toBeFocused(); + await expectNoInternalOverflow(editor); + await page.screenshot({ + path: + pagePolishScreenshotDir(testInfo.project.name) + + '/inventory-correction-editor-narrow-dark.png', + animations: 'disabled', + }); + await editor.getByRole('button', { name: 'Record movement', exact: true }).click(); + await expect( + editor.getByText('The operation could not be confirmed', { exact: true }), + ).toBeVisible(); + await expect.poll(() => received.length).toBe(1); + await editor + .getByRole('button', { name: 'Close', exact: true }) + .filter({ hasText: 'Close' }) + .first() + .click(); + await expect(editor).toBeHidden(); + await page.reload(); + const retained = page.locator('[data-inventory-operation]'); + await expect(retained).toHaveCount(1); + await expect(retained).toContainText('0.000001'); + await expectNoHorizontalOverflow(page); + await expectNoInternalOverflow(retained); + await page.screenshot({ + path: + pagePolishScreenshotDir(testInfo.project.name) + + '/inventory-correction-recovery-narrow-dark.png', + animations: 'disabled', + }); + await retained.getByRole('button', { name: 'Retry saved movement', exact: true }).click(); + await expect(retained).toHaveCount(0); + expect(received).toHaveLength(2); + expect(received[1]).toEqual(received[0]); + expect(commits).toBe(1); + expect(received[0]).toEqual( + expect.objectContaining({ quantity: '0.000001', partId, warehouseId }), + ); + expect( + errors.filter((error) => !error.includes('net::ERR_FAILED')), + errors.join('\n'), + ).toEqual([]); + }); + + test('does not expose stock corrections to reference administrators without financial rights', async ({ + page, + }) => { + const api = new ApiMock(page); + await api.mockAuthenticatedSession(); + await api.mockOrganizationAccess(E2E_ORGANIZATION_ID, { + permissions: ['organization.inventory.read', 'organization.inventory.manage'], + }); + const data = fixtures(); + await mockInventory(page, data, async () => { + throw new Error('A read-only browser action must not mutate stock'); + }); + await page.goto('/organizations/' + E2E_ORGANIZATION_ID + '/inventory/balances'); + await expect(page.locator('[data-inventory-id="balance-1"]')).toContainText('1.000000'); + await expect( + page.getByRole('button', { name: 'Record stock correction', exact: true }), + ).toHaveCount(0); + await expect(page.locator('app-inventory-dataview')).not.toContainText('9123'); + }); +}); diff --git a/tests/e2e/organization/maintenance-costs.mobile.spec.ts b/tests/e2e/organization/maintenance-costs.mobile.spec.ts new file mode 100644 index 000000000..f46e6ca42 --- /dev/null +++ b/tests/e2e/organization/maintenance-costs.mobile.spec.ts @@ -0,0 +1,72 @@ +import { expect, test } from '@playwright/test'; +import { expectMinimumCssPixels, setDarkTheme } from '../support/helpers/appearance'; +import { emulateMobilePlatform } from '../support/helpers/interaction-mode'; +import { + captureMaintenanceCosts, + installMaintenanceCosts, + MAINTENANCE_COST_URL, +} from '../support/helpers/maintenance-costs'; + +for (const dark of [false, true]) + test(`keeps exact private costs and expense recovery reachable by touch in ${dark ? 'dark' : 'light'} mode`, async ({ + page, + context, + baseURL, + }, info) => { + await emulateMobilePlatform(context, info.project.name.includes('Safari') ? 'ios' : 'android'); + if (dark) await setDarkTheme(context, baseURL ?? 'http://localhost:4273'); + const state = await installMaintenanceCosts(page, { closed: true, loseExpenseReply: true }); + await page.goto(MAINTENANCE_COST_URL); + const tablist = page.getByRole('tablist'); + await expect(tablist).toBeVisible(); + const [listBounds, tabsBounds] = await Promise.all([ + tablist.boundingBox(), + Promise.all((await tablist.getByRole('tab').all()).map((tab) => tab.boundingBox())), + ]); + for (const tabBounds of tabsBounds) { + if (!listBounds || !tabBounds) throw new Error('Financial tabs need visible bounds'); + expectMinimumCssPixels(tabBounds.height, 44); + expect(tabBounds.y).toBeGreaterThanOrEqual(listBounds.y - 1); + expect(tabBounds.y + tabBounds.height).toBeLessThanOrEqual( + listBounds.y + listBounds.height + 1, + ); + } + const cards = page.getByTestId('maintenance-cost-cards').first(); + await expect(cards).toBeVisible(); + await expect(cards).toContainText('External repair of the entrance'); + await expect(cards).toContainText('Unknown'); + await captureMaintenanceCosts(page, info, `mobile-contributions-${dark ? 'dark' : 'light'}`); + const expense = page.getByTestId('maintenance-expense-form'); + await expense.getByLabel('Exact amount', { exact: true }).fill('2.123456'); + await expense.getByLabel('Actual date and time', { exact: true }).fill('2025-01-15T14:30'); + await expense + .getByLabel('Description or correction reason', { exact: true }) + .fill('Touch repair fee'); + await expense.getByRole('button', { name: 'Record expense', exact: true }).tap(); + const retry = page.getByRole('button', { name: 'Retry the original declaration', exact: true }); + await expect(retry).toBeVisible(); + await retry.scrollIntoViewIfNeeded(); + const bounds = await retry.boundingBox(); + if (!bounds) throw new Error('Recovery action needs visible bounds'); + expectMinimumCssPixels(bounds.height, 44); + const viewport = page.viewportSize(); + if (!viewport) throw new Error('Mobile context needs its device viewport'); + expect(bounds.x).toBeGreaterThanOrEqual(0); + expect(bounds.x + bounds.width).toBeLessThanOrEqual(viewport.width + 1); + expect(bounds.y).toBeGreaterThanOrEqual(0); + expect(bounds.y + bounds.height).toBeLessThanOrEqual(viewport.height + 1); + await captureMaintenanceCosts(page, info, `mobile-expense-recovery-${dark ? 'dark' : 'light'}`); + await retry.tap(); + await expect(retry).toBeHidden(); + await expect(expense.getByLabel('Exact amount', { exact: true })).toHaveValue(''); + expect(state.expenses).toHaveLength(2); + expect(state.expenses[1]).toEqual(state.expenses[0]); + expect(state.committedExpenses).toBe(1); + await expect(page.getByTestId('maintenance-frozen-total')).toHaveText('10.000000 EUR'); + await page.getByRole('tab', { name: 'Currency and hourly rates', exact: true }).tap(); + await expect(page.getByTestId('maintenance-rate-cards')).toBeVisible(); + await expect(page.getByTestId('maintenance-rate-cards')).toContainText( + '9,007,199,254,740,993.123456 EUR', + ); + await captureMaintenanceCosts(page, info, `mobile-private-settings-${dark ? 'dark' : 'light'}`); + }); diff --git a/tests/e2e/organization/maintenance-costs.spec.ts b/tests/e2e/organization/maintenance-costs.spec.ts new file mode 100644 index 000000000..e9e942f63 --- /dev/null +++ b/tests/e2e/organization/maintenance-costs.spec.ts @@ -0,0 +1,158 @@ +import { expect, test } from '@playwright/test'; +import { + captureMaintenanceCosts, + installMaintenanceCosts, + MAINTENANCE_COST_EXPENSE_ID, + MAINTENANCE_COST_URL, +} from '../support/helpers/maintenance-costs'; + +test.describe('Private maintenance financial dossier', () => { + test('saves revision-zero preparation with exact amounts and only the applicable resource tuple', async ({ + page, + }, info) => { + const state = await installMaintenanceCosts(page); + const errors: string[] = []; + page.on('pageerror', (error) => errors.push(error.message)); + await page.goto(MAINTENANCE_COST_URL); + await expect( + page.getByRole('heading', { name: 'Maintenance costs', exact: true }), + ).toBeVisible(); + await expect(page.getByTestId('maintenance-current-total')).toHaveText('Unknown'); + expect(state.currencyReads).toEqual([]); + expect(state.rateReads).toEqual([]); + const preparation = page.getByTestId('maintenance-planning-form'); + await preparation.getByLabel('Planned budget', { exact: true }).fill('9007199254740993.123456'); + await preparation.getByRole('button', { name: 'Add estimated resource', exact: true }).click(); + await preparation.getByLabel('Description', { exact: true }).fill('Repair supplies'); + await preparation.getByLabel('Quantity', { exact: true }).fill('2'); + await preparation.getByLabel('Unit or hourly amount', { exact: true }).fill('50.000000'); + await preparation.getByLabel('Resource type', { exact: true }).click(); + await page.getByRole('option', { name: 'Work time', exact: true }).click(); + await expect(page.getByRole('option', { name: 'Work time', exact: true })).toBeHidden(); + await expect(preparation.getByLabel('Resource type', { exact: true })).toContainText( + 'Work time', + ); + await preparation + .getByRole('group', { name: 'Estimated resource', exact: true }) + .getByLabel('Estimated minutes', { exact: true }) + .fill('60'); + await captureMaintenanceCosts(page, info, 'preparation-exact-time'); + await preparation + .getByRole('button', { name: 'Save financial preparation', exact: true }) + .click(); + await expect.poll(() => state.preparations.length).toBe(1); + expect(state.preparations[0]).toEqual({ + ifMatch: '"revision-0"', + body: { + plannedBudget: '9007199254740993.123456', + estimatedMinutes: null, + resources: [ + { + kind: 'time', + description: 'Repair supplies', + quantity: null, + unitCost: '50.000000', + estimatedMinutes: 60, + amount: null, + workItemId: null, + }, + ], + }, + }); + await preparation.getByLabel('Resource type', { exact: true }).click(); + await page.getByRole('option', { name: 'External service', exact: true }).click(); + await expect(preparation.getByLabel('Quantity', { exact: true })).toHaveCount(0); + await expect(preparation.getByLabel('Unit or hourly amount', { exact: true })).toHaveCount(0); + await preparation.getByLabel('Estimated amount', { exact: true }).fill('7.123456'); + await preparation + .getByRole('button', { name: 'Save financial preparation', exact: true }) + .click(); + await expect.poll(() => state.preparations.length).toBe(2); + expect(state.preparations[1]?.body.resources?.[0]).toMatchObject({ + kind: 'external', + quantity: null, + unitCost: null, + estimatedMinutes: null, + amount: '7.123456', + }); + expect(state.preparations[1]?.ifMatch).toBe('"revision-1"'); + await captureMaintenanceCosts(page, info, 'preparation-exact-external'); + expect(errors).toEqual([]); + }); + test('retries a committed expense unchanged then appends a signed adjustment without rewriting closure', async ({ + page, + }, info) => { + const state = await installMaintenanceCosts(page, { closed: true, loseExpenseReply: true }); + const errors: string[] = []; + page.on('pageerror', (error) => errors.push(error.message)); + const frozen = structuredClone(state.dossier.frozen); + await page.goto(MAINTENANCE_COST_URL); + const expense = page.getByTestId('maintenance-expense-form'); + await expect(page.getByTestId('maintenance-frozen-total')).toHaveText('10.000000 EUR'); + await expense.getByLabel('Exact amount', { exact: true }).fill('2.123456'); + await expense.getByLabel('Actual date and time', { exact: true }).fill('2025-01-15T14:30'); + await expense + .getByLabel('Description or correction reason', { exact: true }) + .fill('Repair fee after closure'); + await expense.getByRole('button', { name: 'Record expense', exact: true }).click(); + const retry = page.getByRole('button', { name: 'Retry the original declaration', exact: true }); + await expect(retry).toBeVisible(); + await expect(expense.getByLabel('Exact amount', { exact: true })).toBeDisabled(); + await captureMaintenanceCosts(page, info, 'expense-response-lost'); + await retry.click(); + await expect(retry).toBeHidden(); + await expect(expense.getByLabel('Exact amount', { exact: true })).toHaveValue(''); + expect(state.expenses).toHaveLength(2); + expect(state.expenses[1]).toEqual(state.expenses[0]); + expect(state.committedExpenses).toBe(1); + expect(state.dossier.frozen).toEqual(frozen); + const current = page.getByTestId('maintenance-cost-facts').first(); + const original = current + .getByRole('row') + .filter({ hasText: 'External repair of the entrance' }); + await original.getByRole('button', { name: 'Adjust expense', exact: true }).click(); + await expect( + expense.getByRole('button', { name: 'Record adjustment', exact: true }), + ).toBeVisible(); + await expense.getByLabel('Exact amount', { exact: true }).fill('-0.000001'); + await expense.getByLabel('Actual date and time', { exact: true }).fill('2025-01-15T15:30'); + await expense + .getByLabel('Description or correction reason', { exact: true }) + .fill('Documented refund'); + await expect(expense.getByLabel('Exact amount', { exact: true })).toHaveValue('-0.000001'); + await expect(expense.getByLabel('Actual date and time', { exact: true })).toHaveValue( + '2025-01-15T15:30', + ); + await expense.getByRole('button', { name: 'Record adjustment', exact: true }).click(); + await expect.poll(() => state.committedExpenses).toBe(2); + expect(state.expenses[2]).toMatchObject({ + amount: '-0.000001', + adjustmentOf: MAINTENANCE_COST_EXPENSE_ID, + description: 'Documented refund', + }); + expect(state.dossier.frozen).toEqual(frozen); + await expect(page.getByTestId('maintenance-frozen-total')).toHaveText('10.000000 EUR'); + await captureMaintenanceCosts(page, info, 'append-only-correction'); + expect(errors).toEqual([]); + }); + test('keeps finance-read-only settings visible without exposing management controls', async ({ + page, + }, info) => { + const state = await installMaintenanceCosts(page, { closed: true, readonly: true }); + await page.goto(MAINTENANCE_COST_URL); + await expect(page.getByTestId('maintenance-planning-form')).toHaveCount(0); + await expect(page.getByTestId('maintenance-expense-form')).toHaveCount(0); + await page.getByRole('tab', { name: 'Currency and hourly rates', exact: true }).click(); + await expect( + page.getByRole('heading', { name: 'Hourly rate history', exact: true }), + ).toBeVisible(); + await expect(page.getByTestId('maintenance-rate-history')).toContainText( + '9,007,199,254,740,993.123456 EUR', + ); + await expect(page.getByRole('button', { name: 'Add rate', exact: true })).toHaveCount(0); + await expect(page.getByRole('button', { name: 'Save currency', exact: true })).toHaveCount(0); + expect(state.currencyReads).toEqual(['GET']); + expect(state.rateReads).toHaveLength(1); + await captureMaintenanceCosts(page, info, 'private-readonly-settings'); + }); +}); diff --git a/tests/e2e/organization/maintenance-exports.mobile.spec.ts b/tests/e2e/organization/maintenance-exports.mobile.spec.ts new file mode 100644 index 000000000..6a27e46b8 --- /dev/null +++ b/tests/e2e/organization/maintenance-exports.mobile.spec.ts @@ -0,0 +1,55 @@ +import { expect, test } from '@playwright/test'; +import { expectMinimumCssPixels, setDarkTheme } from '../support/helpers/appearance'; +import { emulateMobilePlatform } from '../support/helpers/interaction-mode'; +import { + EXPORT_INITIAL_ID, + installMaintenanceExports, +} from '../support/mocks/maintenance-export-api-mock'; +import { MaintenanceExportsPage } from '../support/pages/maintenance-exports.page'; + +for (const dark of [false, true]) { + test(`keeps published dossier selection, exact archive recovery and explicit import confirmation usable by touch in ${dark ? 'dark' : 'light'} mode`, async ({ + page, + context, + baseURL, + }, info) => { + await emulateMobilePlatform(context, info.project.name.includes('Safari') ? 'ios' : 'android'); + if (dark) await setDarkTheme(context, baseURL ?? 'http://localhost:4273'); + const state = await installMaintenanceExports(page, { loseCreateReply: true }); + const exports = new MaintenanceExportsPage(page); + await exports.goto(); + await exports.prepareExport(); + await exports.capture(info, 'touch-source-selection-' + (dark ? 'dark' : 'light')); + await exports.generate(); + await expect(exports.recover).toBeVisible(); + await exports.recover.scrollIntoViewIfNeeded(); + const bounds = await exports.recover.boundingBox(); + if (!bounds) + throw new Error('The uncertain archive recovery action must have visible touch bounds.'); + expectMinimumCssPixels(bounds.height, 44); + const viewport = page.viewportSize(); + if (!viewport) throw new Error('The mobile project must supply its device viewport.'); + expect(bounds.x).toBeGreaterThanOrEqual(0); + expect(bounds.x + bounds.width).toBeLessThanOrEqual(viewport.width + 1); + await exports.recover.tap(); + await expect(exports.editor).toBeHidden(); + expect(state.createAttempts[1]).toEqual(state.createAttempts[0]); + expect(state.committedArchives).toBe(1); + await expect(exports.detail).toContainText('No external import has been confirmed'); + const originalBytes = state.files.get(EXPORT_INITIAL_ID); + await exports.prepareConfirmation('ERP-TOUCH-IMPORT-0041'); + await exports.capture(info, 'touch-explicit-import-reference-' + (dark ? 'dark' : 'light')); + await exports.confirmImport(); + await expect(exports.detail).toContainText('ERP-TOUCH-IMPORT-0041'); + expect(state.archives.get(EXPORT_INITIAL_ID)?.state).toBe('import_confirmed'); + expect(state.files.get(EXPORT_INITIAL_ID)).toEqual(originalBytes); + const archiveLink = page.getByTestId('maintenance-export-archives').getByRole('link').first(); + await archiveLink.scrollIntoViewIfNeeded(); + const linkBounds = await archiveLink.boundingBox(); + if (!linkBounds) + throw new Error('The retained archive link must have an actual clickable touch target.'); + expectMinimumCssPixels(linkBounds.height, 44); + await exports.detail.scrollIntoViewIfNeeded(); + await exports.capture(info, 'touch-confirmed-retained-archive-' + (dark ? 'dark' : 'light')); + }); +} diff --git a/tests/e2e/organization/maintenance-exports.spec.ts b/tests/e2e/organization/maintenance-exports.spec.ts new file mode 100644 index 000000000..661279ee5 --- /dev/null +++ b/tests/e2e/organization/maintenance-exports.spec.ts @@ -0,0 +1,142 @@ +import { expect, test } from '@playwright/test'; +import { + EXPORT_ADJUSTMENT_ID, + EXPORT_INITIAL_ID, + EXPORT_PRIVATE_ID, + EXPORT_ARCHIVED_CUSTOMER_ID, + EXPORT_ARCHIVED_CUSTOMER_NAME, + EXPORT_SYSTEM, + installMaintenanceExports, +} from '../support/mocks/maintenance-export-api-mock'; +import { MaintenanceExportsPage } from '../support/pages/maintenance-exports.page'; + +test('maps an archived historical customer without restoration and keeps its identity after switching directory status', async ({ + page, +}) => { + const state = await installMaintenanceExports(page, { archivedCustomer: true }); + const exports = new MaintenanceExportsPage(page); + await exports.goto(); + await page.getByRole('button', { name: 'Map a reference', exact: true }).click(); + await exports.editor.getByLabel('External system code', { exact: true }).fill(EXPORT_SYSTEM); + await exports.referenceForm.getByRole('button', { name: 'Archived', exact: true }).click(); + await exports.editor.getByRole('combobox', { name: 'Scoped resource', exact: true }).click(); + await page.getByRole('option', { name: EXPORT_ARCHIVED_CUSTOMER_NAME, exact: true }).click(); + await expect( + exports.editor.getByLabel('External resource reference', { exact: true }), + ).toHaveValue('ERP-CUSTOMER-OLD'); + await exports.referenceForm.getByRole('button', { name: 'Active', exact: true }).click(); + await expect( + exports.editor.getByRole('combobox', { name: 'Scoped resource', exact: true }), + ).toContainText(EXPORT_ARCHIVED_CUSTOMER_NAME); + await exports.editor + .getByLabel('External resource reference', { exact: true }) + .fill('ERP-CUSTOMER-REVISED'); + await exports.referenceForm + .getByRole('button', { name: 'Save reviewed reference', exact: true }) + .click(); + await expect(exports.editor).toBeHidden(); + expect(state.customerQueries).toContain('true'); + expect(state.reference).toMatchObject({ + resourceType: 'customer', + resourceId: EXPORT_ARCHIVED_CUSTOMER_ID, + revision: 2, + reference: 'ERP-CUSTOMER-REVISED', + }); + expect(state.mappingAttempts).toHaveLength(1); + expect(state.mappingAttempts[0]?.ifMatch).toBe('"revision-1"'); + await expect(exports.workspace).not.toContainText('Private billing contact'); +}); + +test('recovers one archive, preserves original download bytes and appends a separately acknowledged ERP adjustment', async ({ + page, +}, info) => { + const state = await installMaintenanceExports(page, { loseCreateReply: true }); + const exports = new MaintenanceExportsPage(page); + await exports.goto(); + await expect( + page.getByRole('heading', { name: 'No retained export yet', exact: true }), + ).toBeVisible(); + await expect(page.getByRole('table')).toHaveCount(0); + await exports.prepareExport(); + await expect( + exports.editor.getByLabel('Include internal costs in this private export', { exact: true }), + ).toHaveCount(0); + await exports.capture(info, 'published-source-selection-with-blocked-history'); + await exports.generate(); + await expect(exports.recover).toBeVisible(); + await exports.recover.click(); + await expect(exports.editor).toBeHidden(); + expect(state.createAttempts).toHaveLength(2); + expect(state.createAttempts[1]).toEqual(state.createAttempts[0]); + expect(state.committedArchives).toBe(1); + expect(state.createAttempts[0]?.includeInternalCosts).toBe(false); + await expect(exports.detail).toContainText('No external import has been confirmed'); + await expect( + page.getByTestId('maintenance-export-archives').getByText('Private costs', { exact: true }), + ).toHaveCount(0); + const originalMetadata = state.archives.get(EXPORT_INITIAL_ID); + const originalJson = await exports.download('JSON'); + const originalCsv = await exports.download('CSV'); + expect(originalJson.toString()).toBe(state.files.get(EXPORT_INITIAL_ID)?.json); + expect(originalCsv.toString()).toBe(state.files.get(EXPORT_INITIAL_ID)?.csv); + expect(originalJson.toString()).toContain('ERP-SITE-OLD'); + await exports.mapSite('ERP-SITE-CORRECTED'); + expect(state.mappingAttempts[0]?.ifMatch).toBe('"revision-1"'); + expect(state.reference.revision).toBe(2); + expect(await exports.download('JSON')).toEqual(originalJson); + await exports.prepareAdjustment( + 'Validated correction following the supplier return and external reference review', + ); + await exports.capture(info, 'append-linked-adjustment'); + await exports.generateAdjustment(); + expect(state.adjustmentAttempts[0]?.ifMatch).toBe('"revision-1"'); + expect(state.archives.get(EXPORT_ADJUSTMENT_ID)).toMatchObject({ + adjustmentOf: EXPORT_INITIAL_ID, + originalExportId: EXPORT_INITIAL_ID, + state: 'generated', + }); + expect(state.archives.get(EXPORT_INITIAL_ID)).toEqual(originalMetadata); + const adjustmentJson = await exports.download('JSON'); + expect(adjustmentJson.toString()).toContain('ERP-SITE-CORRECTED'); + expect(adjustmentJson.toString()).toContain(EXPORT_INITIAL_ID); + expect(adjustmentJson).not.toEqual(originalJson); + await exports.prepareConfirmation('ERP-IMPORT-2026-0041'); + await exports.capture(info, 'explicit-actual-import-reference'); + await exports.confirmImport(); + await expect(exports.detail).toContainText('External import confirmed'); + await expect(exports.detail).toContainText('ERP-IMPORT-2026-0041'); + expect(state.confirmAttempts[0]).toMatchObject({ + exportId: EXPORT_ADJUSTMENT_ID, + ifMatch: '"revision-1"', + body: { externalImportReference: 'ERP-IMPORT-2026-0041' }, + }); + expect(await exports.download('JSON')).toEqual(adjustmentJson); + expect(state.archives.get(EXPORT_INITIAL_ID)).toEqual(originalMetadata); + await exports.detail.getByRole('link', { name: 'Open original export', exact: true }).click(); + await expect(exports.detail).toContainText('No external import has been confirmed'); + expect(await exports.download('CSV')).toEqual(originalCsv); + expect(state.committedArchives).toBe(2); + await exports.capture(info, 'original-bytes-retained-after-confirmed-adjustment'); +}); + +test('allows dedicated financial readers to download private archives without exposing management or import-confirmation controls', async ({ + page, +}, info) => { + const state = await installMaintenanceExports(page, { finance: true, readOnly: true }); + const exports = new MaintenanceExportsPage(page); + await exports.goto(EXPORT_PRIVATE_ID); + await expect(exports.detail).toContainText('Private archive including internal costs'); + await expect(page.getByTestId('maintenance-export-create')).toHaveCount(0); + await expect( + exports.detail.getByRole('button', { name: 'Append adjustment', exact: true }), + ).toHaveCount(0); + await expect( + exports.detail.getByRole('button', { name: 'Confirm external import', exact: true }), + ).toHaveCount(0); + const bytes = await exports.download('JSON'); + expect(bytes.toString()).toBe(state.files.get(EXPORT_PRIVATE_ID)?.json); + expect(bytes.toString()).toContain('"internalCost":"12.123456"'); + expect(state.createAttempts).toHaveLength(0); + expect(state.confirmAttempts).toHaveLength(0); + await exports.capture(info, 'private-retained-archive-with-read-only-financial-rights'); +}); diff --git a/tests/e2e/organization/maintenance-plans.spec.ts b/tests/e2e/organization/maintenance-plans.spec.ts new file mode 100644 index 000000000..f9d3015aa --- /dev/null +++ b/tests/e2e/organization/maintenance-plans.spec.ts @@ -0,0 +1,272 @@ +import { expect, test, type Page, type Route } from '@playwright/test'; +import type { + CreateMaintenancePlanInput, + MaintenancePlanOutput, + UpdateMaintenancePlanInput, +} from '@features/organization/features/maintenance-schedules/models'; +import { E2E_ORGANIZATION_ID } from '../support/fixtures/api-fixtures'; +import { E2E_EQUIPMENT_ID, equipmentOutput } from '../support/fixtures/equipment-fixtures'; +import { + collectConsoleErrors, + expectNoHorizontalOverflow, + expectNoInternalOverflow, + setDarkTheme, +} from '../support/helpers/appearance'; +import { pagePolishScreenshotDir } from '../support/helpers/screenshot-dir'; +import { ApiMock } from '../support/mocks/api-mock'; +import { MaintenancePlansPage } from '../support/pages/maintenance-plans.page'; + +const planPath: string = `/api/organizations/${E2E_ORGANIZATION_ID}/maintenance/plans`; +const equipmentName: string = 'EXT-N — North hall extinguisher'; + +interface PlanMockState { + readonly plans: MaintenancePlanOutput[]; + readonly created: CreateMaintenancePlanInput[]; + readonly updates: (UpdateMaintenancePlanInput & { id: string })[]; + readonly kinds: string[]; +} + +function annualControl(): MaintenancePlanOutput { + return { + '@id': `${planPath}/annual-control`, + '@type': 'MaintenancePlan', + id: 'annual-control', + organizationId: E2E_ORGANIZATION_ID, + equipmentId: E2E_EQUIPMENT_ID, + equipmentType: 'fire_extinguisher', + name: 'Annual extinguisher control', + operationKind: 'control', + interval: 'P1Y', + cadenceMode: 'fixed', + calendarTimezone: 'Europe/Paris', + anchorAt: '2027-01-31T00:00:00+01:00', + nextDueAt: '2028-01-31T00:00:00+01:00', + active: true, + openOccurrence: null, + }; +} + +async function json(route: Route, status: number, body: unknown): Promise { + await route.fulfill({ status, contentType: 'application/ld+json', body: JSON.stringify(body) }); +} + +async function installPlans(page: Page): Promise { + const api: ApiMock = new ApiMock(page); + await api.mockAuthenticatedSession(); + const equipment = { ...equipmentOutput(), name: 'North hall extinguisher', assetCode: 'EXT-N' }; + await api.mockEquipmentList(E2E_ORGANIZATION_ID, [equipment]); + const state: PlanMockState = { plans: [annualControl()], created: [], updates: [], kinds: [] }; + await page.route( + new RegExp(`/api/organizations/${E2E_ORGANIZATION_ID}/maintenance/engine(\\?.*)?$`), + async (route) => { + if (route.request().method() !== 'GET') return route.fallback(); + await json(route, 200, { mode: 'plans', preparedCount: 1, conflicts: [] }); + }, + ); + await page.route(new RegExp(`${planPath}(?:/[^?]*)?(?:\\?.*)?$`), async (route) => { + const url: URL = new URL(route.request().url()); + const method: string = route.request().method(); + if (url.pathname === planPath && method === 'GET') { + const kind: string = url.searchParams.get('operationKind') ?? ''; + expect(['control', 'maintenance']).toContain(kind); + state.kinds.push(kind); + const member: MaintenancePlanOutput[] = state.plans.filter( + (plan) => plan.operationKind === kind, + ); + await json(route, 200, { + '@id': planPath, + '@type': 'Collection', + member, + totalItems: member.length, + }); + return; + } + if (url.pathname === planPath && method === 'POST') { + const input: CreateMaintenancePlanInput = route + .request() + .postDataJSON() as CreateMaintenancePlanInput; + state.created.push(input); + const prepared: MaintenancePlanOutput = { + ...annualControl(), + id: 'monthly-maintenance', + '@id': `${planPath}/monthly-maintenance`, + name: input.name, + operationKind: input.operationKind, + interval: input.interval, + anchorAt: `${input.anchorOn}T00:00:00+01:00`, + nextDueAt: `${input.nextDueOn}T00:00:00+01:00`, + active: false, + }; + state.plans.push(prepared); + await json(route, 201, prepared); + return; + } + const preview: RegExpMatchArray | null = url.pathname.match(/\/plans\/([^/]+)\/preview$/); + if (preview && method === 'GET') { + const plan: MaintenancePlanOutput | undefined = state.plans.find( + (candidate) => candidate.id === preview[1], + ); + if (!plan) throw new Error('Preview requested an unknown plan.'); + await json(route, 200, { + dates: + plan.operationKind === 'control' + ? [ + '2028-01-31T00:00:00+01:00', + '2029-01-31T00:00:00+01:00', + '2030-01-31T00:00:00+01:00', + ] + : [ + '2027-02-28T00:00:00+01:00', + '2027-03-31T00:00:00+02:00', + '2027-04-30T00:00:00+02:00', + ], + }); + return; + } + const item: RegExpMatchArray | null = url.pathname.match(/\/plans\/([^/]+)$/); + if (item && method === 'PATCH') { + const input = route.request().postDataJSON() as UpdateMaintenancePlanInput; + const index: number = state.plans.findIndex((candidate) => candidate.id === item[1]); + if (index < 0) throw new Error('Configuration requested an unknown plan.'); + if (input.active !== undefined) expect(Object.keys(input)).toEqual(['active']); + else expect(Object.keys(input)).toEqual(['name']); + state.updates.push({ id: item[1], ...input }); + state.plans[index] = { ...state.plans[index], ...input }; + await json(route, 200, state.plans[index]); + return; + } + await route.fallback(); + }); + return state; +} + +test.describe('Equipment operation plans', () => { + test('renames an open operation while its original calendar stays disabled and the request contains only its name', async ({ + page, + }, testInfo) => { + const errors: readonly string[] = collectConsoleErrors(page); + const state: PlanMockState = await installPlans(page); + state.plans[0] = { + ...state.plans[0], + openOccurrence: { + id: 'open-control', + dueAt: '2028-01-31T00:00:00+01:00', + attempt: 1, + status: 'open', + interventionId: 'control-campaign', + }, + }; + await page.setViewportSize({ width: 375, height: 812 }); + const plans: MaintenancePlansPage = new MaintenancePlansPage(page); + await plans.goto(E2E_ORGANIZATION_ID); + await plans + .row('annual-control') + .getByRole('button', { name: 'Configure', exact: true }) + .click(); + await expect(page.getByTestId('maintenance-plan-calendar-locked')).toContainText( + 'You can still change the operation name.', + ); + await expect(plans.every).toBeDisabled(); + await expect(plans.unit).toBeDisabled(); + await expect(plans.anchor).toBeDisabled(); + await expect(plans.firstDue).toBeDisabled(); + await expect(plans.name).toBeEnabled(); + await plans.name.fill('North hall annual control'); + await plans.name.focus(); + await page.keyboard.press('Tab'); + await expect(plans.form.getByRole('button', { name: 'Cancel', exact: true })).toBeFocused(); + await expectNoHorizontalOverflow(page); + await expectNoInternalOverflow(plans.form); + await page.screenshot({ + path: testInfo.outputPath('maintenance-plan-open-calendar.png'), + animations: 'disabled', + }); + await plans.save.click(); + await expect(plans.form).toBeHidden(); + await expect(plans.row('annual-control')).toContainText('North hall annual control'); + expect(state.updates).toEqual([{ id: 'annual-control', name: 'North hall annual control' }]); + expect(state.plans[0].nextDueAt).toBe('2028-01-31T00:00:00+01:00'); + expect(state.plans[0].openOccurrence?.id).toBe('open-control'); + expect(errors, errors.join('\n')).toEqual([]); + }); + + test('prepares a monthly calendar with a separate first deadline, reviews server dates and explicitly activates then pauses it without changing annual controls', async ({ + page, + }, testInfo) => { + const errors: readonly string[] = collectConsoleErrors(page); + const state: PlanMockState = await installPlans(page); + const plans: MaintenancePlansPage = new MaintenancePlansPage(page); + await plans.goto(E2E_ORGANIZATION_ID); + await expect(plans.row('annual-control')).toBeVisible(); + await plans.prepareMonthlyMaintenance('Monthly equipment service', equipmentName); + await expect(plans.form).toBeHidden(); + await expect(plans.preview).toContainText('2027-02-28'); + await expect(plans.preview).toContainText('2027-03-31'); + await expect(plans.preview).toContainText('2027-04-30'); + expect(state.created).toEqual([ + { + equipmentId: E2E_EQUIPMENT_ID, + name: 'Monthly equipment service', + operationKind: 'maintenance', + interval: 'P1M', + anchorOn: '2027-01-31', + nextDueOn: '2027-02-28', + }, + ]); + expect(state.updates).toEqual([]); + await plans.maintenance.click(); + await expect(plans.row('monthly-maintenance')).toContainText('Prepared'); + await expect(plans.row('annual-control')).toHaveCount(0); + await page.screenshot({ + path: `${pagePolishScreenshotDir(testInfo.project.name)}/maintenance-plans-calendar-preview-light.png`, + animations: 'disabled', + }); + await plans.activate.click(); + await expect(plans.pause).toBeVisible(); + await expect(plans.row('monthly-maintenance')).not.toContainText('Prepared'); + await plans.pause.click(); + await expect(plans.activate).toBeVisible(); + expect(state.updates).toEqual([ + { id: 'monthly-maintenance', active: true }, + { id: 'monthly-maintenance', active: false }, + ]); + await plans.controls.click(); + await plans + .row('annual-control') + .getByRole('button', { name: 'Preview dates', exact: true }) + .click(); + await expect(plans.preview).toContainText('2028-01-31'); + await expect(plans.preview).toContainText('2029-01-31'); + await expect(plans.preview).toContainText('2030-01-31'); + expect(state.plans.find((plan) => plan.id === 'annual-control')?.interval).toBe('P1Y'); + expect(state.plans.find((plan) => plan.id === 'annual-control')?.active).toBe(true); + expect(state.kinds).toContain('control'); + expect(state.kinds).toContain('maintenance'); + expect(errors, errors.join('\n')).toEqual([]); + }); + + test('keeps the native calendar preparation readable and keyboard reachable on a narrow dark desktop', async ({ + page, + context, + baseURL, + }, testInfo) => { + await setDarkTheme(context, baseURL ?? 'http://localhost:4273'); + await page.setViewportSize({ width: 375, height: 812 }); + await installPlans(page); + const plans: MaintenancePlansPage = new MaintenancePlansPage(page); + await plans.goto(E2E_ORGANIZATION_ID); + await plans.newPlan.click(); + await plans.name.fill('North hall monthly fire equipment maintenance'); + await plans.anchor.fill('2027-01-31'); + await plans.firstDue.fill('2027-02-28'); + await plans.unit.focus(); + await page.keyboard.press('Tab'); + await expect(plans.anchor).toBeFocused(); + await expectNoHorizontalOverflow(page); + await expectNoInternalOverflow(plans.form); + await page.screenshot({ + path: `${pagePolishScreenshotDir(testInfo.project.name)}/maintenance-plans-preparation-narrow-dark.png`, + animations: 'disabled', + }); + }); +}); diff --git a/tests/e2e/organization/maintenance-reports.mobile.spec.ts b/tests/e2e/organization/maintenance-reports.mobile.spec.ts new file mode 100644 index 000000000..0157f0d29 --- /dev/null +++ b/tests/e2e/organization/maintenance-reports.mobile.spec.ts @@ -0,0 +1,210 @@ +import { expect, test, type Locator } from '@playwright/test'; +import { E2E_ORGANIZATION_ID } from '../support/fixtures/api-fixtures'; +import { + collectConsoleErrors, + expectMinimumCssPixels, + expectNoHorizontalOverflow, + expectNoInternalOverflow, + setDarkTheme, +} from '../support/helpers/appearance'; +import { emulateMobilePlatform } from '../support/helpers/interaction-mode'; +import { + LONG_ALLOCATION_NAME, + LONG_CUSTOMER_NAME, + LONG_SITE_NAME, + MAINTENANCE_REPORTS_URL, + OTHER_DOSSIER_NAME, + REPORT_CUSTOMER_ID, + REPORT_EQUIPMENT_ID, + REPORT_INTERVENTION_ID, + REPORT_SITE_ID, + SOURCE_DOSSIER_NAME, + captureMaintenanceReport, + installMaintenanceReports, +} from '../support/helpers/maintenance-reports'; + +/** Critical finance actions retain a real 44px phone target before receiving touch input. */ +async function tapFinancialAction(action: Locator): Promise { + await action.scrollIntoViewIfNeeded(); + const bounds = await action.boundingBox(); + if (!bounds) throw new Error('The financial action must be visible on the phone.'); + expectMinimumCssPixels(bounds.height, 44); + expectMinimumCssPixels(bounds.width, 44); + await action.tap(); +} + +test.beforeEach(async ({ context, browserName }) => { + await emulateMobilePlatform(context, browserName === 'webkit' ? 'ios' : 'android'); +}); + +test.describe('Private economic pilotage with native phone interactions', () => { + for (const theme of ['light', 'dark'] as const) { + test(`keeps exact amounts, unknown valuations and named source navigation readable with financial read alone in ${theme} mode`, async ({ + page, + context, + baseURL, + }, info) => { + test.setTimeout(60_000); + const errors = collectConsoleErrors(page); + const pageErrors: string[] = []; + page.on('pageerror', (error) => pageErrors.push(error.message)); + if (theme === 'dark') await setDarkTheme(context, baseURL ?? 'http://localhost:4273'); + const state = await installMaintenanceReports(page, { incomplete: true, compact: true }); + await page.goto(MAINTENANCE_REPORTS_URL); + await expect(page.locator('html')).toHaveAttribute('data-interaction-mode', 'mobile'); + await expect(page.locator('html')).toHaveAttribute('data-theme', theme); + expect(await page.evaluate(() => matchMedia('(pointer: coarse)').matches)).toBe(true); + expect(await page.evaluate(() => navigator.maxTouchPoints)).toBeGreaterThan(0); + const reportPage = page.getByTestId('maintenance-reports-page'); + await expect(reportPage).toBeVisible(); + const totals = page.getByTestId('maintenance-report-totals'); + await expect(totals.getByTestId('maintenance-report-current')).toHaveText('Unknown'); + await expect(totals.getByTestId('maintenance-report-frozen')).toHaveText('Unknown'); + await expect(totals.getByTestId('maintenance-report-variance')).toHaveText('Unknown'); + await expect(totals).toContainText('9,007,199,254,741,018.123456 EUR'); + await expect(page.getByTestId('maintenance-report-missing-snapshot')).toContainText( + 'Published dossiers without an immutable closure snapshot: 1.', + ); + expect(state.directoryRequests).toHaveLength(0); + expect(state.operationalRequests).toEqual([]); + + const allocatedCards = page.getByTestId('maintenance-report-cards').first(); + await expect(allocatedCards).toBeVisible(); + await expect(allocatedCards.getByRole('listitem')).toHaveCount(2); + const namedAllocation = allocatedCards.getByRole('listitem').filter({ + has: page.getByRole('heading', { name: LONG_ALLOCATION_NAME, exact: true }), + }); + await expect(namedAllocation).toContainText('9,007,199,254,740,993.123456 EUR'); + await expect(namedAllocation).toContainText('8.000000 EUR'); + await expectNoInternalOverflow(namedAllocation); + await Promise.all( + (await namedAllocation.locator('dd').all()).map((amount) => + expectNoInternalOverflow(amount), + ), + ); + await namedAllocation.scrollIntoViewIfNeeded(); + await captureMaintenanceReport(page, info, `phone-${theme}-exact-allocation`); + + const incompleteAllocation = allocatedCards.getByRole('listitem').nth(1); + await expect(incompleteAllocation).toContainText('Unknown'); + await expect(incompleteAllocation).toContainText('Known subtotal'); + await expect(incompleteAllocation).toContainText('20.000000 EUR'); + await expect(incompleteAllocation).toContainText('Unvalued contributions: 1'); + await expectNoInternalOverflow(incompleteAllocation); + const unallocated = page.getByTestId('maintenance-report-unallocated'); + await expect( + unallocated.getByRole('heading', { name: 'Unallocated costs', exact: true }), + ).toBeVisible(); + await expect( + unallocated + .getByTestId('maintenance-report-cards') + .getByText('5.000000 EUR', { exact: true }), + ).toHaveCount(1); + await expect(unallocated).toContainText('100.000000 EUR'); + await expectNoHorizontalOverflow(page); + await expectNoInternalOverflow(reportPage); + await expectNoInternalOverflow(unallocated); + + await tapFinancialAction( + namedAllocation.getByRole('button', { name: 'View source dossiers', exact: true }), + ); + const sources = page + .getByRole('dialog', { name: 'Source financial dossiers', exact: true }) + .locator('hlm-sheet-content'); + await expect(sources).toBeVisible(); + await expect(sources).toContainText(SOURCE_DOSSIER_NAME); + await expect(sources.getByText(OTHER_DOSSIER_NAME, { exact: false })).toHaveCount(0); + const sourceLink = sources.getByRole('link', { name: 'Open financial dossier', exact: true }); + await expect(sourceLink).toHaveCount(1); + await expect(sourceLink).toHaveAttribute( + 'href', + `/organizations/${E2E_ORGANIZATION_ID}/maintenance-costs?interventionId=${REPORT_INTERVENTION_ID}`, + ); + const sourceLinkBounds = await sourceLink.boundingBox(); + if (!sourceLinkBounds) throw new Error('The selected financial dossier must be reachable.'); + expectMinimumCssPixels(sourceLinkBounds.height, 44); + await expectNoInternalOverflow(sources); + expect(state.directoryRequests).toHaveLength(0); + await captureMaintenanceReport(page, info, `phone-${theme}-exact-source-subset`); + await tapFinancialAction(sources.getByRole('button', { name: 'Close', exact: true })); + await expect(sources).toBeHidden(); + + await tapFinancialAction( + page.getByRole('button', { + name: 'Browse financial dossiers and choose a scope', + exact: true, + }), + ); + const directory = page.getByTestId('maintenance-financial-directory'); + await expect(directory).toBeVisible(); + await expect.poll(() => state.directoryRequests.length).toBe(1); + const search = directory.getByLabel('Work reference or name', { exact: true }); + await search.tap(); + await search.fill('Entrance'); + await tapFinancialAction( + directory.getByRole('button', { name: 'Search dossiers', exact: true }), + ); + await expect.poll(() => state.directoryRequests.at(-1)?.['search']).toBe('Entrance'); + const directoryItems = directory.getByTestId('maintenance-financial-items'); + await expect(directoryItems.getByRole('listitem')).toHaveCount(1); + const dossierEntry = directoryItems.getByRole('listitem'); + await expect(dossierEntry).toContainText(LONG_SITE_NAME); + await expect(dossierEntry).toContainText(LONG_CUSTOMER_NAME); + await expectNoInternalOverflow(directory); + await expectNoInternalOverflow(dossierEntry); + await dossierEntry.scrollIntoViewIfNeeded(); + await captureMaintenanceReport(page, info, `phone-${theme}-named-directory`); + await tapFinancialAction( + dossierEntry.getByRole('button', { name: /^Limit to equipment\s*:\s*EX-001\b/ }), + ); + await expect + .poll(() => state.reportRequests.at(-1)?.['equipmentId']) + .toBe(REPORT_EQUIPMENT_ID); + expect(state.reportRequests.at(-1)).toMatchObject({ + equipmentId: REPORT_EQUIPMENT_ID, + siteId: REPORT_SITE_ID, + customerId: REPORT_CUSTOMER_ID, + groupBy: 'equipment', + page: '1', + }); + const filters = page.getByTestId('maintenance-report-filter-form'); + await expect(filters).toContainText(LONG_SITE_NAME); + await expect(filters).toContainText(LONG_CUSTOMER_NAME); + await expect(filters).toContainText('EX-001'); + await tapFinancialAction(filters.getByRole('button', { name: 'Site', exact: true })); + await tapFinancialAction( + filters.getByRole('button', { name: 'Read economic report', exact: true }), + ); + await expect.poll(() => state.reportRequests.at(-1)?.['groupBy']).toBe('site'); + expect(state.reportRequests.at(-1)).toMatchObject({ + equipmentId: REPORT_EQUIPMENT_ID, + siteId: REPORT_SITE_ID, + customerId: REPORT_CUSTOMER_ID, + }); + await expectNoInternalOverflow(filters); + await filters.scrollIntoViewIfNeeded(); + await captureMaintenanceReport(page, info, `phone-${theme}-named-filters`); + + await tapFinancialAction( + namedAllocation.getByRole('button', { name: 'View source dossiers', exact: true }), + ); + await expect(sources).toBeVisible(); + await tapFinancialAction( + sources.getByRole('link', { name: 'Open financial dossier', exact: true }), + ); + await expect(page).toHaveURL( + new RegExp(`maintenance-costs\\?interventionId=${REPORT_INTERVENTION_ID}$`), + ); + await expect(page.getByTestId('maintenance-current-total')).toHaveText('Unknown'); + expect(state.privateDossierRequests).toHaveLength(1); + expect(state.operationalRequests).toEqual([]); + await expect(page.getByRole('link', { name: 'Open intervention', exact: true })).toHaveCount( + 0, + ); + await expect(page.getByTestId('maintenance-planning-form')).toHaveCount(0); + await expect(page.getByTestId('maintenance-expense-form')).toHaveCount(0); + expect(errors).toEqual([]); + expect(pageErrors).toEqual([]); + }); + } +}); diff --git a/tests/e2e/organization/maintenance-reports.spec.ts b/tests/e2e/organization/maintenance-reports.spec.ts new file mode 100644 index 000000000..74530e71c --- /dev/null +++ b/tests/e2e/organization/maintenance-reports.spec.ts @@ -0,0 +1,234 @@ +import { expect, test } from '@playwright/test'; +import { + collectConsoleErrors, + expectNoHorizontalOverflow, + expectNoInternalOverflow, +} from '../support/helpers/appearance'; +import { + captureMaintenanceReport, + installMaintenanceReports, + LONG_ALLOCATION_NAME, + LONG_SITE_NAME, + MAINTENANCE_REPORTS_URL, + OTHER_DOSSIER_NAME, + REPORT_EQUIPMENT_ID, + REPORT_INTERVENTION_ID, + REPORT_SITE_ID, + SOURCE_DOSSIER_NAME, +} from '../support/helpers/maintenance-reports'; + +test.describe('Private maintenance economic reports', () => { + test('keeps exact full-filter totals independent of destination pages and counts general work once', async ({ + page, + }, info) => { + await page.setViewportSize({ width: 1920, height: 1000 }); + const errors = collectConsoleErrors(page); + const state = await installMaintenanceReports(page); + await page.goto(MAINTENANCE_REPORTS_URL); + await expect(page.getByTestId('maintenance-report-current')).toHaveText( + '9,007,199,254,741,018.123456 EUR', + ); + await expect(page.getByTestId('maintenance-report-budget')).toHaveText('100.000000 EUR'); + const allocations = page.getByTestId('maintenance-report-table').first(); + await expect(allocations.getByTestId('maintenance-report-row-count')).toHaveText( + '30 of 31 rows shown', + ); + await expect( + allocations.getByTestId('maintenance-report-grid').locator('tbody tr'), + ).toHaveCount(30); + const unallocated = page.getByTestId('maintenance-report-unallocated'); + await expect( + unallocated.getByTestId('maintenance-report-grid').locator('tbody tr'), + ).toHaveCount(1); + await expect(unallocated.getByTestId('maintenance-report-grid')).toContainText('5.000000 EUR'); + expect(state.directoryRequests).toEqual([]); + await page.getByTestId('maintenance-report-current').scrollIntoViewIfNeeded(); + await expectNoHorizontalOverflow(page); + await expectNoInternalOverflow(page.getByTestId('maintenance-reports-page')); + await expectNoInternalOverflow(allocations.getByTestId('maintenance-report-grid')); + await captureMaintenanceReport(page, info, 'full-filter-exact-totals'); + const firstRow = allocations.getByTestId('maintenance-report-grid').locator('tbody tr').first(); + await firstRow.scrollIntoViewIfNeeded(); + await Promise.all((await firstRow.getByRole('cell').all()).map(expectNoInternalOverflow)); + await captureMaintenanceReport(page, info, 'allocation-exact-columns'); + await allocations.getByTestId('maintenance-report-page-next').click(); + await expect.poll(() => state.reportRequests.at(-1)?.['page']).toBe('2'); + await expect(allocations.getByTestId('maintenance-report-row-count')).toHaveText( + '1 of 31 rows shown', + ); + await expect( + allocations.getByTestId('maintenance-report-grid').locator('tbody tr'), + ).toHaveCount(1); + await expect(page.getByTestId('maintenance-report-current')).toHaveText( + '9,007,199,254,741,018.123456 EUR', + ); + expect(state.reportRequests.at(-1)).toMatchObject({ + page: '2', + itemsPerPage: '30', + groupBy: 'equipment', + }); + expect(state.operationalRequests).toEqual([]); + expect(errors).toEqual([]); + }); + + test('keeps unknown valuations and missing historical closure separate from known current contributions', async ({ + page, + }, info) => { + await page.setViewportSize({ width: 1440, height: 1000 }); + const state = await installMaintenanceReports(page, { incomplete: true, compact: true }); + await page.goto(MAINTENANCE_REPORTS_URL); + await expect(page.getByTestId('maintenance-report-current')).toHaveText('Unknown'); + await expect(page.getByTestId('maintenance-report-totals')).toContainText( + 'Known: 9,007,199,254,741,018.123456 EUR', + ); + await expect(page.getByTestId('maintenance-report-frozen')).toHaveText('Unknown'); + await expect(page.getByTestId('maintenance-report-missing-snapshot')).toContainText( + 'Published dossiers without an immutable closure snapshot: 1', + ); + await expect( + page.getByRole('heading', { name: 'Some valuations are unknown', exact: true }), + ).toBeVisible(); + await expect(page.getByTestId('maintenance-report-unallocated')).toContainText('5.000000 EUR'); + await expect(page.getByTestId('maintenance-report-procurement')).toContainText( + 'independently of equipment, site or customer filters', + ); + await expectNoHorizontalOverflow(page); + await expectNoInternalOverflow(page.getByTestId('maintenance-reports-page')); + await captureMaintenanceReport(page, info, 'incomplete-and-historical-closure'); + expect(state.operationalRequests).toEqual([]); + }); + + test('applies inclusive dates grouping and named scopes through the paginated financial directory alone', async ({ + page, + }, info) => { + const state = await installMaintenanceReports(page); + await page.goto(MAINTENANCE_REPORTS_URL); + const form = page.getByTestId('maintenance-report-filter-form'); + await form.getByLabel('From', { exact: true }).fill('2025-01-01'); + await form.getByLabel('Through', { exact: true }).fill('2025-01-31'); + await form.getByRole('button', { name: 'Site', exact: true }).click(); + await form.getByRole('button', { name: 'Read economic report', exact: true }).click(); + await expect + .poll(() => state.reportRequests.at(-1)) + .toMatchObject({ + from: '2025-01-01', + to: '2025-01-31', + groupBy: 'site', + page: '1', + itemsPerPage: '30', + }); + await page + .getByRole('button', { name: 'Browse financial dossiers and choose a scope', exact: true }) + .click(); + const directory = page.getByTestId('maintenance-financial-directory'); + await expect(directory.getByTestId('maintenance-financial-row-count')).toHaveText( + '30 of 31 rows shown', + ); + await directory.getByTestId('maintenance-financial-page-next').click(); + await expect.poll(() => state.directoryRequests.at(-1)?.['page']).toBe('2'); + await expect(directory.getByTestId('maintenance-financial-row-count')).toHaveText( + '1 of 31 rows shown', + ); + await directory.getByLabel('Work reference or name', { exact: true }).fill('Entrance'); + await directory.getByRole('button', { name: 'Search dossiers', exact: true }).click(); + await expect + .poll(() => state.directoryRequests.at(-1)) + .toMatchObject({ page: '1', search: 'Entrance' }); + await expect( + directory.getByRole('heading', { name: `1 · ${SOURCE_DOSSIER_NAME}`, exact: true }), + ).toBeVisible(); + await directory + .getByRole('button', { name: new RegExp(`^Limit to site\\s*:\\s*${LONG_SITE_NAME}$`) }) + .click(); + await expect.poll(() => state.reportRequests.at(-1)?.['siteId']).toBe(REPORT_SITE_ID); + await expect(form).toContainText(LONG_SITE_NAME); + await directory.getByRole('button', { name: /^Limit to equipment\s*:\s*EX-001\b/ }).click(); + await expect.poll(() => state.reportRequests.at(-1)?.['equipmentId']).toBe(REPORT_EQUIPMENT_ID); + await expect(form).toContainText('EX-001'); + await form.getByRole('button', { name: 'Clear all named filters', exact: true }).click(); + await expect.poll(() => state.reportRequests.at(-1)?.['equipmentId']).toBeUndefined(); + await expect( + form.getByRole('button', { name: 'Clear all named filters', exact: true }), + ).toHaveCount(0); + await form.scrollIntoViewIfNeeded(); + await captureMaintenanceReport(page, info, 'named-financial-scope'); + expect(state.operationalRequests).toEqual([]); + }); + + test('refuses an oversized date window even with a named scope and recovers with shorter inclusive dates', async ({ + page, + }, info) => { + const state = await installMaintenanceReports(page, { refuseBroad: true, compact: true }); + await page.goto(MAINTENANCE_REPORTS_URL); + const error = page.getByTestId('maintenance-report-error'); + await expect(error).toContainText('More than 500 source dossiers'); + await expect(error).toContainText('No partial total is displayed'); + await expect(page.getByTestId('maintenance-report-totals')).toHaveCount(0); + await page + .getByRole('button', { name: 'Browse financial dossiers and choose a scope', exact: true }) + .click(); + const directory = page.getByTestId('maintenance-financial-directory'); + await directory + .getByRole('button', { name: new RegExp(`^Limit to site\\s*:\\s*${LONG_SITE_NAME}$`) }) + .first() + .click(); + await expect.poll(() => state.reportRequests.at(-1)?.['siteId']).toBe(REPORT_SITE_ID); + await expect(error).toContainText('More than 500 source dossiers'); + await expect(page.getByTestId('maintenance-report-totals')).toHaveCount(0); + const form = page.getByTestId('maintenance-report-filter-form'); + await form.getByLabel('From', { exact: true }).fill('2025-01-25'); + await form.getByLabel('Through', { exact: true }).fill('2025-01-31'); + await form.getByRole('button', { name: 'Read economic report', exact: true }).click(); + await expect + .poll(() => state.reportRequests.at(-1)) + .toMatchObject({ from: '2025-01-25', to: '2025-01-31', siteId: REPORT_SITE_ID }); + await expect(error).toHaveCount(0); + await expect(page.getByTestId('maintenance-report-current')).toHaveText( + '9,007,199,254,741,018.123456 EUR', + ); + await page.getByTestId('maintenance-report-current').scrollIntoViewIfNeeded(); + await captureMaintenanceReport(page, info, 'oversized-scope-recovered'); + expect(state.operationalRequests).toEqual([]); + }); + + test('opens only the exact allocation source dossier using financial read permission and no extra identity lookup', async ({ + page, + }, info) => { + const errors = collectConsoleErrors(page); + const state = await installMaintenanceReports(page, { compact: true }); + await page.goto(MAINTENANCE_REPORTS_URL); + const allocation = page.getByTestId('maintenance-report-table').first(); + const card = allocation + .getByRole('row') + .filter({ hasText: LONG_ALLOCATION_NAME }) + .or(allocation.getByRole('listitem').filter({ hasText: LONG_ALLOCATION_NAME })) + .filter({ visible: true }); + await card.getByRole('button', { name: 'View source dossiers', exact: true }).click(); + const sheet = page + .getByRole('dialog', { name: 'Source financial dossiers', exact: true }) + .locator('hlm-sheet-content'); + await expect(sheet).toContainText(SOURCE_DOSSIER_NAME); + await expect(sheet).not.toContainText(OTHER_DOSSIER_NAME); + const link = sheet.getByRole('link', { name: 'Open financial dossier', exact: true }); + await expect(link).toHaveCount(1); + await expect(link).toHaveAttribute( + 'href', + new RegExp(`interventionId=${REPORT_INTERVENTION_ID}$`), + ); + expect(state.directoryRequests).toEqual([]); + expect(state.privateDossierRequests).toEqual([]); + await expectNoInternalOverflow(sheet); + await captureMaintenanceReport(page, info, 'exact-named-source-sheet'); + await link.click(); + await expect.poll(() => state.privateDossierRequests.length).toBe(1); + await expect( + page.getByRole('heading', { name: 'Maintenance costs', exact: true }), + ).toBeVisible(); + await expect(page.getByTestId('maintenance-expense-form')).toHaveCount(0); + await expect(page).toHaveURL( + new RegExp(`/maintenance-costs\\?interventionId=${REPORT_INTERVENTION_ID}$`), + ); + expect(state.operationalRequests).toEqual([]); + expect(errors).toEqual([]); + }); +}); diff --git a/tests/e2e/organization/mobile-visual-review.spec.ts b/tests/e2e/organization/mobile-visual-review.spec.ts index 8b71ca3d4..e184f908f 100644 --- a/tests/e2e/organization/mobile-visual-review.spec.ts +++ b/tests/e2e/organization/mobile-visual-review.spec.ts @@ -75,7 +75,7 @@ for (const mode of MOBILE_VISUAL_MODES) { }, { name: 'theme-preference', value: mode.theme, url: baseURL ?? 'http://localhost:4273' }, ]); - await mockMobileVisualWorkspace(page); + await mockMobileVisualWorkspace(page, route.id); const review = new MobileVisualReviewPage(page); await review.inspect(route, mode, info); }); diff --git a/tests/e2e/organization/park-queues.mobile.spec.ts b/tests/e2e/organization/park-queues.mobile.spec.ts new file mode 100644 index 000000000..6f315f36f --- /dev/null +++ b/tests/e2e/organization/park-queues.mobile.spec.ts @@ -0,0 +1,108 @@ +import { expect, test } from '@playwright/test'; +import { E2E_ORGANIZATION_ID } from '../support/fixtures/api-fixtures'; +import { E2E_FACILITY_ID } from '../support/fixtures/facility-fixtures'; +import { E2E_INSPECTION_ID } from '../support/fixtures/inspection-fixtures'; +import { + collectConsoleErrors, + expectNoHorizontalOverflow, + expectNoInternalOverflow, + setDarkTheme, +} from '../support/helpers/appearance'; +import { emulateMobilePlatform } from '../support/helpers/interaction-mode'; +import { CUSTOMER_ID, CUSTOMER_NAME, SITE_NAME, mockPark } from '../support/mocks/park-api-mock'; +import { OrganizationParkPage } from '../support/pages/organization-park.page'; + +test('opens the three customer and site queues through native touch navigation', async ({ + page, + context, + browserName, + baseURL, +}, testInfo) => { + test.setTimeout(60_000); + const originalViewport = page.viewportSize(); + expect(originalViewport?.width).toBeLessThan(500); + await emulateMobilePlatform(context, browserName === 'webkit' ? 'ios' : 'android'); + const consoleErrors = collectConsoleErrors(page); + const requests = await mockPark(page, 'service_provider'); + const park = new OrganizationParkPage(page, true); + await park.gotoDashboard(E2E_ORGANIZATION_ID); + await expect(page.locator('html')).toHaveAttribute('data-interaction-mode', 'mobile'); + expect(await page.evaluate(() => matchMedia('(pointer: coarse)').matches)).toBe(true); + expect(await page.evaluate(() => navigator.maxTouchPoints)).toBeGreaterThan(0); + await expect(page.locator('#organization-mobile-navigation a').first()).toHaveAttribute( + 'data-destination', + 'assets', + ); + await expect(park.queue('Unavailable equipment')).toContainText('2'); + await expect(park.queue('Controls to prepare')).toContainText('4'); + await expect(park.queue('Anomalies to address')).toContainText('1'); + + const destinations = [ + { name: 'Unavailable equipment', queue: 'unavailable', total: 3 }, + { name: 'Controls to prepare', queue: 'controls', total: 5 }, + { name: 'Anomalies to address', queue: 'anomalies', total: 2 }, + ] as const; + const verifyDestination = async ( + index: number, + destination: (typeof destinations)[number], + ): Promise => { + if (index > 0) await park.gotoDashboard(E2E_ORGANIZATION_ID); + await park.selectCustomer(CUSTOMER_NAME); + await park.selectSite(SITE_NAME); + await park.showAllEquipment(); + await expect(park.queue(destination.name)).toContainText(String(destination.total)); + await expectNoHorizontalOverflow(page); + await expectNoInternalOverflow(park.queues); + if (index === 0) { + await park.queues.screenshot({ + path: testInfo.outputPath('park-queues-mobile-light.png'), + animations: 'disabled', + }); + } + await park.openQueue(destination.name); + await expect(page).toHaveURL(new RegExp(`queue=${destination.queue}`, 'u')); + await expect(page).toHaveURL(new RegExp(`customerId=${CUSTOMER_ID}`, 'u')); + await expect(page).toHaveURL(new RegExp(`facility=${E2E_FACILITY_ID}`, 'u')); + await expect(park.park.getByTestId('assets-detail-panel')).toBeVisible(); + if (destination.queue === 'anomalies') { + await expect(park.anomalyPane.getByTestId('non-conformity-row')).toHaveCount( + destination.total, + ); + } else { + await expect(park.equipmentLinks).toHaveCount(destination.total); + await expect(park.equipmentPane.getByTestId('assets-equipment-cards')).toBeVisible(); + } + await expectNoHorizontalOverflow(page); + }; + await verifyDestination(0, destinations[0]); + await verifyDestination(1, destinations[1]); + await verifyDestination(2, destinations[2]); + expect( + requests.some( + (request) => + request.path.endsWith('/park-anomalies') && + request.family === null && + request.customerId === CUSTOMER_ID && + request.facilityId === E2E_FACILITY_ID, + ), + ).toBe(true); + await park.openSourceInspection('Missing tamper seal'); + await expect(page).toHaveURL(new RegExp(`/inspections/${E2E_INSPECTION_ID}$`, 'u')); + await expect(page.getByTestId('inspection-detail-summary')).toBeVisible(); + + await setDarkTheme(context, baseURL ?? 'http://localhost:4273'); + await park.gotoDashboard(E2E_ORGANIZATION_ID); + await park.selectCustomer(CUSTOMER_NAME); + await park.selectSite(SITE_NAME); + await park.showAllEquipment(); + await expect(park.queue('Anomalies to address')).toContainText('2'); + await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark'); + await expectNoHorizontalOverflow(page); + await expectNoInternalOverflow(park.queues); + await park.queues.screenshot({ + path: testInfo.outputPath('park-queues-mobile-dark.png'), + animations: 'disabled', + }); + expect(page.viewportSize()).toEqual(originalViewport); + expect(consoleErrors).toEqual([]); +}); diff --git a/tests/e2e/organization/park-queues.spec.ts b/tests/e2e/organization/park-queues.spec.ts new file mode 100644 index 000000000..6796836da --- /dev/null +++ b/tests/e2e/organization/park-queues.spec.ts @@ -0,0 +1,121 @@ +import { expect, test } from '@playwright/test'; +import { E2E_ORGANIZATION_ID } from '../support/fixtures/api-fixtures'; +import { E2E_FACILITY_ID } from '../support/fixtures/facility-fixtures'; +import { E2E_INSPECTION_ID } from '../support/fixtures/inspection-fixtures'; +import { + collectConsoleErrors, + expectNoHorizontalOverflow, + expectNoInternalOverflow, + setDarkTheme, +} from '../support/helpers/appearance'; +import { CUSTOMER_ID, CUSTOMER_NAME, SITE_NAME, mockPark } from '../support/mocks/park-api-mock'; +import { OrganizationParkPage } from '../support/pages/organization-park.page'; + +test.describe('Fire equipment park action queues', () => { + for (const profile of ['operator', 'service_provider'] as const) { + test(`keeps the three queues and their destinations scoped for the ${profile} profile`, async ({ + page, + context, + baseURL, + }, testInfo) => { + const consoleErrors = collectConsoleErrors(page); + const requests = await mockPark(page, profile); + const park = new OrganizationParkPage(page); + await park.gotoDashboard(E2E_ORGANIZATION_ID); + await expect(park.queues.locator('app-stat-tile')).toHaveCount(3); + await expect(park.queue('Unavailable equipment')).toContainText('2'); + await expect(park.queue('Controls to prepare')).toContainText('4'); + await expect(park.queue('Anomalies to address')).toContainText('1'); + expect( + requests.some( + (request) => + request.path.endsWith('/equipment-summary') && + request.family === 'fire' && + request.customerId === null, + ), + ).toBe(true); + if (profile === 'service_provider') await park.selectCustomer(CUSTOMER_NAME); + await park.selectSite(SITE_NAME); + await park.showAllEquipment(); + await expect(park.queue('Unavailable equipment')).toContainText('3'); + await expect(park.queue('Controls to prepare')).toContainText('5'); + await expect(park.queue('Anomalies to address')).toContainText('2'); + await expect + .poll(() => + requests.some( + (request) => + request.path.endsWith(`/facilities/${E2E_FACILITY_ID}/equipment-summary`) && + request.family === null && + request.customerId === (profile === 'service_provider' ? CUSTOMER_ID : null), + ), + ) + .toBe(true); + await expectNoHorizontalOverflow(page); + await expectNoInternalOverflow(park.queues); + await park.queues.screenshot({ + path: testInfo.outputPath('park-queues-light.png'), + animations: 'disabled', + }); + + const controlsDestination = await park + .queue('Controls to prepare') + .getByRole('link') + .getAttribute('href'); + const anomaliesDestination = await park + .queue('Anomalies to address') + .getByRole('link') + .getAttribute('href'); + expect(controlsDestination).toBeTruthy(); + expect(anomaliesDestination).toBeTruthy(); + if (!controlsDestination || !anomaliesDestination) { + throw new Error('Park queue links must provide their scoped destinations.'); + } + await park.queue('Unavailable equipment').getByRole('link').click(); + await expect(page).toHaveURL(/queue=unavailable/u); + await expect(park.equipmentLinks).toHaveCount(3); + await expectNoHorizontalOverflow(page); + await page.goto(controlsDestination); + await expect(page).toHaveURL(/queue=controls/u); + await expect(park.equipmentLinks).toHaveCount(5); + expect( + requests.some( + (request) => + request.path.endsWith('/equipment') && + request.family === null && + request.facilityId === E2E_FACILITY_ID && + request.customerId === (profile === 'service_provider' ? CUSTOMER_ID : null), + ), + ).toBe(true); + await page.goto(anomaliesDestination); + await expect(page).toHaveURL(/queue=anomalies/u); + await expect(park.anomalyPane.getByTestId('non-conformity-row')).toHaveCount(2); + await expect + .poll(() => + requests.some( + (request) => + request.path.endsWith('/park-anomalies') && + request.family === null && + request.facilityId === E2E_FACILITY_ID && + request.customerId === (profile === 'service_provider' ? CUSTOMER_ID : null), + ), + ) + .toBe(true); + await park.openSourceInspection('Missing tamper seal'); + await expect(page).toHaveURL(new RegExp(`/inspections/${E2E_INSPECTION_ID}$`, 'u')); + await setDarkTheme(context, baseURL ?? 'http://127.0.0.1:4273'); + await park.gotoDashboard(E2E_ORGANIZATION_ID); + if (profile === 'service_provider') await park.selectCustomer(CUSTOMER_NAME); + await park.selectSite(SITE_NAME); + await park.showAllEquipment(); + await expect(park.queue('Anomalies to address')).toContainText('2'); + await expect(page.locator('html')).toHaveAttribute('data-theme', 'dark'); + await expectNoHorizontalOverflow(page); + await expectNoInternalOverflow(park.queues); + await park.queues.screenshot({ + path: testInfo.outputPath('park-queues-dark.png'), + animations: 'disabled', + }); + expect(consoleErrors).toEqual([]); + }); + } +}); diff --git a/tests/e2e/organization/procurement.mobile.spec.ts b/tests/e2e/organization/procurement.mobile.spec.ts new file mode 100644 index 000000000..a7884dfd9 --- /dev/null +++ b/tests/e2e/organization/procurement.mobile.spec.ts @@ -0,0 +1,50 @@ +import { expect, test } from '@playwright/test'; +import { expectMinimumCssPixels, setDarkTheme } from '../support/helpers/appearance'; +import { emulateMobilePlatform } from '../support/helpers/interaction-mode'; +import { captureProcurement, installProcurement } from '../support/helpers/procurement'; +import { ProcurementPage } from '../support/pages/procurement.page'; + +for (const dark of [false, true]) { + test(`keeps exact purchase, delivery and response recovery reachable by touch in ${dark ? 'dark' : 'light'} mode`, async ({ + page, + context, + baseURL, + }, info) => { + await emulateMobilePlatform(context, info.project.name.includes('Safari') ? 'ios' : 'android'); + if (dark) await setDarkTheme(context, baseURL ?? 'http://localhost:4273'); + const state = await installProcurement(page, { loseReceiptReply: true }); + const purchases = new ProcurementPage(page); + await purchases.goto(); + await purchases.createSupplier(); + await purchases.prepareOrder(); + await captureProcurement(page, info, 'touch-purchase-draft-' + (dark ? 'dark' : 'light')); + await purchases.saveAndPlaceOrder(); + await purchases.prepareReceipt(); + await captureProcurement(page, info, 'touch-partial-delivery-' + (dark ? 'dark' : 'light')); + await purchases.submitReceipt(); + await expect(purchases.retry).toBeVisible(); + await purchases.retry.scrollIntoViewIfNeeded(); + const bounds = await purchases.retry.boundingBox(); + if (!bounds) + throw new Error('The physical-operation recovery action needs visible touch bounds.'); + expectMinimumCssPixels(bounds.height, 44); + const viewport = page.viewportSize(); + if (!viewport) throw new Error('The mobile project needs its real device viewport.'); + expect(bounds.x).toBeGreaterThanOrEqual(0); + expect(bounds.x + bounds.width).toBeLessThanOrEqual(viewport.width + 1); + await purchases.retry.tap(); + await expect(purchases.editor).toBeHidden(); + expect(state.receiveAttempts[1]).toEqual(state.receiveAttempts[0]); + expect(state.committedReceipts).toBe(1); + await purchases.openReceipts(); + await purchases.prepareReturn(); + await captureProcurement(page, info, 'touch-motivated-return-' + (dark ? 'dark' : 'light')); + await purchases.submitReturn(); + await purchases.showReturnHistory(); + await expect(page.getByTestId('procurement-returns')).toContainText( + 'Awaiting inventory reconciliation', + ); + await page.getByTestId('procurement-returns').scrollIntoViewIfNeeded(); + await captureProcurement(page, info, 'touch-return-history-' + (dark ? 'dark' : 'light')); + }); +} diff --git a/tests/e2e/organization/procurement.spec.ts b/tests/e2e/organization/procurement.spec.ts new file mode 100644 index 000000000..71e017312 --- /dev/null +++ b/tests/e2e/organization/procurement.spec.ts @@ -0,0 +1,127 @@ +import { expect, test } from '@playwright/test'; +import { + captureProcurement, + installProcurement, + PROCUREMENT_EQUIPMENT_ID, + PROCUREMENT_RECEIPT_ID, + PROCUREMENT_RETURN_ID, + PROCUREMENT_WAREHOUSE_ID, + PROCUREMENT_SUPPLIER_NAME, + PROCUREMENT_ARCHIVED_SUPPLIER_NAME, +} from '../support/helpers/procurement'; +import { ProcurementPage } from '../support/pages/procurement.page'; + +test('browses active, archived and all suppliers using explicit server archive filters', async ({ + page, +}) => { + const state = await installProcurement(page, { supplierDirectory: true }); + const purchases = new ProcurementPage(page); + await purchases.goto(); + await expect(purchases.workspace).toContainText(PROCUREMENT_SUPPLIER_NAME); + await expect(purchases.workspace).not.toContainText(PROCUREMENT_ARCHIVED_SUPPLIER_NAME); + const filter = page.getByRole('combobox', { name: 'Supplier archive filter', exact: true }); + await filter.click(); + await page.getByRole('option', { name: 'Archived suppliers', exact: true }).click(); + await expect(purchases.workspace).toContainText(PROCUREMENT_ARCHIVED_SUPPLIER_NAME); + await expect(purchases.workspace).not.toContainText(PROCUREMENT_SUPPLIER_NAME); + await filter.click(); + await page.getByRole('option', { name: 'All suppliers', exact: true }).click(); + await expect(purchases.workspace).toContainText(PROCUREMENT_SUPPLIER_NAME); + await expect(purchases.workspace).toContainText(PROCUREMENT_ARCHIVED_SUPPLIER_NAME); + expect(state.supplierQueries).toEqual(['false', 'true', 'all']); +}); + +test('retains one exact partial delivery after response loss and reconciles a motivated physical return', async ({ + page, +}, info) => { + const state = await installProcurement(page, { loseReceiptReply: true }); + const purchases = new ProcurementPage(page); + await purchases.goto(); + await purchases.createSupplier(); + await purchases.prepareOrder(); + await captureProcurement(page, info, 'exact-part-purchase-draft'); + await purchases.saveAndPlaceOrder(); + await purchases.prepareReceipt(); + await captureProcurement(page, info, 'native-partial-receipt'); + await purchases.submitReceipt(); + await expect(purchases.retry).toBeVisible(); + await purchases.retry.click(); + await expect(purchases.editor).toBeHidden(); + expect(state.committedReceipts).toBe(1); + expect(state.receiveAttempts).toHaveLength(2); + expect(state.receiveAttempts[1]).toEqual(state.receiveAttempts[0]); + expect(state.receiveAttempts[0]?.body).toMatchObject({ + quantity: '0.500001', + warehouseId: PROCUREMENT_WAREHOUSE_ID, + }); + await expect(purchases.detail).toContainText('Awaiting delivery: 0.500001 piece'); + await purchases.openReceipts(); + await purchases.prepareReturn(); + await purchases.submitReturn(); + const receipt = page.getByTestId('procurement-receipt-' + PROCUREMENT_RECEIPT_ID); + await expect(receipt).toContainText('Return movement awaiting reconciliation: 0.250001 piece'); + await purchases.showReturnHistory(); + const returned = page.getByTestId('procurement-return-' + PROCUREMENT_RETURN_ID); + await expect(returned).toContainText('Awaiting inventory reconciliation'); + const original = { + quantity: state.returned?.quantity, + reason: state.returned?.reason, + clientOperationId: state.returned?.clientOperationId, + }; + await captureProcurement(page, info, 'physical-return-awaiting-reconciliation'); + await returned.getByRole('button', { name: 'Reconcile inventory movement', exact: true }).click(); + await purchases.confirmOperation(); + await expect(purchases.confirmation).toBeHidden(); + await expect(returned).toContainText('Inventory reversal confirmed'); + await expect(receipt).not.toContainText('Return movement awaiting reconciliation'); + expect(state.committedReturns).toBe(1); + expect(state.returned).toMatchObject(original); + expect(state.reconcileAttempts[0]?.ifMatch).toBe('"revision-1"'); + await captureProcurement(page, info, 'partial-delivery-and-confirmed-return'); +}); + +test('keeps a quota-blocked hardware receipt and creates one reserve identity through an explicit new attempt', async ({ + page, +}, info) => { + const state = await installProcurement(page, { hardware: true }); + const purchases = new ProcurementPage(page); + await purchases.goto(); + await purchases.createSupplier(); + await purchases.prepareOrder(true); + await expect(purchases.editor.getByLabel('Internal unit cost', { exact: true })).toHaveCount(0); + await captureProcurement(page, info, 'reserve-equipment-draft-without-financial-rights'); + await purchases.saveAndPlaceOrder(); + await purchases.prepareReceipt(true); + await expect(purchases.editor.getByLabel('Receiving warehouse', { exact: true })).toHaveCount(0); + await purchases.submitReceipt(); + await expect(purchases.editor).toBeHidden(); + await purchases.openReceipts(); + const receipt = page.getByTestId('procurement-receipt-' + PROCUREMENT_RECEIPT_ID); + await expect(receipt).toContainText('Awaiting reserve equipment'); + await expect(receipt).not.toContainText('Internal unit cost'); + await receipt.getByRole('button', { name: 'Create reserve equipment', exact: true }).click(); + await purchases.confirmOperation(); + await expect(purchases.confirmation).toBeHidden(); + await expect(receipt).toContainText('organization quota was reached'); + expect(state.createdEquipment).toBe(0); + expect(state.receipt?.quantity).toBe('1.000000'); + await captureProcurement(page, info, 'quota-blocked-retained-physical-receipt'); + await receipt.getByRole('button', { name: 'Create reserve equipment', exact: true }).click(); + await purchases.confirmOperation(); + await expect(purchases.retry).toBeVisible(); + await purchases.retry.click(); + await expect(purchases.confirmation).toBeHidden(); + expect(state.individualizeAttempts).toHaveLength(3); + expect(state.individualizeAttempts[1]?.body.clientOperationId).not.toBe( + state.individualizeAttempts[0]?.body.clientOperationId, + ); + expect(state.individualizeAttempts[0]?.ifMatch).toBe('"revision-1"'); + expect(state.individualizeAttempts[1]?.ifMatch).toBe('"revision-2"'); + expect(state.individualizeAttempts[2]).toEqual(state.individualizeAttempts[1]); + expect(state.createdEquipment).toBe(1); + expect(state.receipt?.equipmentIds).toEqual([PROCUREMENT_EQUIPMENT_ID]); + await expect( + receipt.getByRole('link', { name: 'Open reserve equipment dossier', exact: true }), + ).toHaveAttribute('href', new RegExp(PROCUREMENT_EQUIPMENT_ID + '$')); + await captureProcurement(page, info, 'single-reserve-identity-after-response-recovery'); +}); diff --git a/tests/e2e/organization/service-requests.mobile.spec.ts b/tests/e2e/organization/service-requests.mobile.spec.ts new file mode 100644 index 000000000..3ce31a9eb --- /dev/null +++ b/tests/e2e/organization/service-requests.mobile.spec.ts @@ -0,0 +1,72 @@ +import { expect, test } from '@playwright/test'; +import { E2E_ORGANIZATION_ID } from '../support/fixtures/api-fixtures'; +import { expectMinimumCssPixels, setDarkTheme } from '../support/helpers/appearance'; +import { emulateMobilePlatform } from '../support/helpers/interaction-mode'; +import { + captureServiceRequest, + installServiceRequests, + SERVICE_REQUEST_EQUIPMENT_LABEL, + SERVICE_REQUEST_ID, + SERVICE_REQUEST_SITE_NAME, +} from '../support/helpers/service-requests'; +import { ServiceRequestsPage } from '../support/pages/service-requests.page'; + +for (const dark of [false, true]) { + test(`keeps site qualification and uncertain conversion recovery reachable by touch in ${dark ? 'dark' : 'light'} mode`, async ({ + page, + context, + baseURL, + }, info) => { + await emulateMobilePlatform(context, info.project.name.includes('Safari') ? 'ios' : 'android'); + if (dark) await setDarkTheme(context, baseURL ?? 'http://localhost:4273'); + const state = await installServiceRequests(page, { loseConversionResponse: true }); + const requests = new ServiceRequestsPage(page); + await requests.goto(E2E_ORGANIZATION_ID); + await requests.prepareSiteRequest( + SERVICE_REQUEST_SITE_NAME, + 'Damaged extinguisher gauge at the main fire-safety entrance', + 'Identify the equipment during qualification, retain this site report, then explicitly organize its repair.', + ); + await captureServiceRequest(page, info, `mobile-site-description-${dark ? 'dark' : 'light'}`); + await requests.submitRequest(); + await expect(requests.editor).toBeHidden(); + await requests.prepareSiteQualification( + SERVICE_REQUEST_EQUIPMENT_LABEL, + 'Repair the damaged gauge.', + ); + await captureServiceRequest( + page, + info, + `mobile-equipment-qualification-${dark ? 'dark' : 'light'}`, + ); + await requests.confirmQualification(); + await expect(requests.editor).toBeHidden(); + await requests.prepareConversion(); + await requests.confirmConversion(); + await expect(requests.retryConversion).toBeVisible(); + await requests.retryConversion.scrollIntoViewIfNeeded(); + const bounds = await requests.retryConversion.boundingBox(); + if (!bounds) throw new Error('The conversion recovery action must have visible touch bounds.'); + expectMinimumCssPixels(bounds.height, 44); + const viewport = page.viewportSize(); + if (!viewport) throw new Error('A mobile project must supply its device viewport.'); + expect(bounds.x).toBeGreaterThanOrEqual(0); + expect(bounds.x + bounds.width).toBeLessThanOrEqual(viewport.width + 1); + expect(bounds.y).toBeGreaterThanOrEqual(0); + expect(bounds.y + bounds.height).toBeLessThanOrEqual(viewport.height + 1); + await captureServiceRequest( + page, + info, + `mobile-conversion-recovery-${dark ? 'dark' : 'light'}`, + ); + await requests.retryConversion.tap(); + await expect(requests.editor).toBeHidden(); + await expect(requests.correctiveLink).toBeVisible(); + expect(state.conversions).toHaveLength(2); + expect(state.conversions[1]).toEqual(state.conversions[0]); + expect(state.committedLinks).toBe(1); + expect(state.newWorkCreated).toBe(1); + expect(state.requests.get(SERVICE_REQUEST_ID)?.status).toBe('converted'); + await captureServiceRequest(page, info, `mobile-converted-dossier-${dark ? 'dark' : 'light'}`); + }); +} diff --git a/tests/e2e/organization/service-requests.spec.ts b/tests/e2e/organization/service-requests.spec.ts new file mode 100644 index 000000000..459266c3e --- /dev/null +++ b/tests/e2e/organization/service-requests.spec.ts @@ -0,0 +1,134 @@ +import { expect, test } from '@playwright/test'; +import { E2E_ORGANIZATION_ID } from '../support/fixtures/api-fixtures'; +import { setDarkTheme } from '../support/helpers/appearance'; +import { + captureServiceRequest, + installServiceRequests, + SERVICE_REQUEST_EQUIPMENT_ID, + SERVICE_REQUEST_EQUIPMENT_LABEL, + SERVICE_REQUEST_ID, + SERVICE_REQUEST_INTERVENTION_ID, + SERVICE_REQUEST_SITE_ID, + SERVICE_REQUEST_SITE_NAME, + SERVICE_REQUEST_TASK_ID, + SERVICE_REQUEST_WORK_NAME, +} from '../support/helpers/service-requests'; +import { ServiceRequestsPage } from '../support/pages/service-requests.page'; + +test.describe('Maintenance request qualification and work conversion', () => { + test('creates a site request, identifies its equipment and retries the exact conversion after its committed response is lost', async ({ + page, + }, info) => { + const pageErrors: string[] = []; + page.on('pageerror', (error) => pageErrors.push(error.message)); + const state = await installServiceRequests(page, { loseConversionResponse: true }); + const requests = new ServiceRequestsPage(page); + await requests.goto(E2E_ORGANIZATION_ID); + await requests.prepareSiteRequest( + SERVICE_REQUEST_SITE_NAME, + 'Repair the entrance extinguisher', + 'The site control reports a damaged pressure gauge at the main entrance.', + ); + await captureServiceRequest(page, info, 'site-request-description-light'); + await requests.submitRequest(); + await expect(requests.editor).toBeHidden(); + await expect(page).toHaveURL( + new RegExp(`/organizations/${E2E_ORGANIZATION_ID}/service-requests/${SERVICE_REQUEST_ID}$`), + ); + expect(state.created).toHaveLength(1); + expect(state.created[0]).toMatchObject({ + siteId: SERVICE_REQUEST_SITE_ID, + title: 'Repair the entrance extinguisher', + priority: 'normal', + }); + expect(state.created[0].equipmentId ?? null).toBeNull(); + expect(state.committedLinks).toBe(0); + await requests.prepareSiteQualification( + SERVICE_REQUEST_EQUIPMENT_LABEL, + 'Identify FIRE-12 and repair its damaged pressure gauge.', + ); + await captureServiceRequest(page, info, 'site-request-qualification-light'); + await requests.confirmQualification(); + await expect(requests.editor).toBeHidden(); + await expect(requests.convert).toBeVisible(); + expect(state.qualifications).toEqual([ + { + body: { + equipmentId: SERVICE_REQUEST_EQUIPMENT_ID, + note: 'Identify FIRE-12 and repair its damaged pressure gauge.', + }, + ifMatch: '"revision-1"', + }, + ]); + expect(state.siteEquipmentQueries.length).toBeGreaterThan(0); + expect(state.requests.get(SERVICE_REQUEST_ID)?.revision).toBe(3); + expect(state.requests.get(SERVICE_REQUEST_ID)?.targetSnapshot.customer).toEqual({ + id: '710e8400-e29b-41d4-a716-446655710006', + name: 'North site operator', + }); + await requests.prepareConversion(); + await requests.confirmConversion(); + await expect(requests.retryConversion).toBeVisible(); + await expect( + requests.actions.getByRole('button', { name: 'Create corrective intervention', exact: true }), + ).toBeDisabled(); + expect(state.conversions).toHaveLength(1); + expect(state.conversions[0].ifMatch).toBe('"revision-3"'); + expect(state.committedLinks).toBe(1); + expect(state.newWorkCreated).toBe(1); + expect(state.targetRetired).toBe(true); + const readsAfterCommit = state.targetReads.length; + await captureServiceRequest(page, info, 'committed-conversion-response-lost-light'); + await requests.retryConversion.click(); + await expect(requests.editor).toBeHidden(); + await expect(requests.correctiveLink).toBeVisible(); + expect(state.conversions).toHaveLength(2); + expect(state.conversions[1]).toEqual(state.conversions[0]); + expect(state.targetReads).toHaveLength(readsAfterCommit); + expect(state.committedLinks).toBe(1); + expect(state.newWorkCreated).toBe(1); + await expect(requests.correctiveLink).toHaveAttribute( + 'href', + `/organizations/${E2E_ORGANIZATION_ID}/interventions/${SERVICE_REQUEST_INTERVENTION_ID}?targetEquipment=${SERVICE_REQUEST_EQUIPMENT_ID}&workAction=repair`, + ); + await expect(requests.detail.getByTestId('service-request-work-link')).toHaveCount(1); + await captureServiceRequest(page, info, 'converted-request-dossier-light'); + expect(pageErrors).toEqual([]); + }); + + test('links a real pending repair task from the equipment open-work projection without creating duplicate work', async ({ + page, + context, + baseURL, + }, info) => { + await setDarkTheme(context, baseURL ?? 'http://localhost:4273'); + const state = await installServiceRequests(page, { qualified: true, existingWork: true }); + const requests = new ServiceRequestsPage(page); + await requests.goto(E2E_ORGANIZATION_ID, SERVICE_REQUEST_ID); + await expect(requests.convert).toBeVisible(); + await requests.prepareConversion(SERVICE_REQUEST_WORK_NAME); + await expect( + requests.actions.getByRole('button', { name: 'Link this corrective work', exact: true }), + ).toBeEnabled(); + await captureServiceRequest(page, info, 'existing-repair-selection-dark'); + await requests.confirmConversion(true); + await expect(requests.editor).toBeHidden(); + await expect(requests.correctiveLink).toBeVisible(); + expect(state.conversions).toHaveLength(1); + expect(state.conversions[0].ifMatch).toBe('"revision-2"'); + expect(state.conversions[0].body).toEqual({ + clientOperationId: state.conversions[0].body.clientOperationId, + existingInterventionId: state.work[0].interventionId, + existingTaskId: state.work[0].workItemId, + }); + expect(state.requests.get(SERVICE_REQUEST_ID)?.taskId).toBe(SERVICE_REQUEST_TASK_ID); + expect(state.newWorkCreated).toBe(0); + expect(state.committedLinks).toBe(1); + await expect(requests.detail.getByTestId('service-request-work-link')).toHaveCount(1); + await expect(requests.correctiveLink).toHaveAttribute( + 'href', + `/organizations/${E2E_ORGANIZATION_ID}/interventions/${state.work[0].interventionId}?targetEquipment=${SERVICE_REQUEST_EQUIPMENT_ID}&workAction=repair`, + ); + await captureServiceRequest(page, info, 'existing-repair-linked-dossier-dark'); + }); +}); diff --git a/tests/e2e/playwright.config.ts b/tests/e2e/playwright.config.ts index e46ed7e67..03960e7e2 100644 --- a/tests/e2e/playwright.config.ts +++ b/tests/e2e/playwright.config.ts @@ -140,7 +140,9 @@ export default defineConfig({ */ webServer: { cwd: projectRoot, - command: `npx ng serve --configuration=e2e --port=${E2E_PORT}`, + // A document reload can cancel Angular's background component-HMR imports. + // Test the fixed application build while retaining normal lazy route imports. + command: `npx ng serve --configuration=e2e --hmr=false --port=${E2E_PORT}`, // Angular accepts document requests before its first compilation has // produced executable chunks. The entry bundle is the readiness signal. url: `http://localhost:${E2E_PORT}/main.js`, diff --git a/tests/e2e/ssr/api-stub.cjs b/tests/e2e/ssr/api-stub.cjs index f032e10bf..7d76abeff 100644 --- a/tests/e2e/ssr/api-stub.cjs +++ b/tests/e2e/ssr/api-stub.cjs @@ -25,6 +25,7 @@ function createApiStub(appOrigin, onShutdown) { const requests = []; const unexpected = []; const registeredFixtures = new Map(); + const caseFixtures = new Map(); const handler = (request, response) => { const url = new URL(request.url, 'https://127.0.0.1'); const origin = request.headers.origin; @@ -54,6 +55,56 @@ function createApiStub(appOrigin, onShutdown) { response.end(JSON.stringify({ requests, unexpected })); return; } + if ( + request.method === 'POST' && + url.pathname === '/__harness/fixtures' && + !url.search && + !origin + ) { + let body = ''; + request.setEncoding('utf8'); + request.on('data', (chunk) => { + body += chunk; + }); + request.on('end', () => { + try { + if (body.length > 1_048_576) throw new Error('Fixture payload is too large.'); + const input = JSON.parse(body); + if (!Array.isArray(input.fixtures) || input.fixtures.length > 20) + throw new Error('A bounded fixture list is required.'); + const next = new Map(); + for (const fixture of input.fixtures) { + if ( + fixture.method !== 'GET' || + typeof fixture.path !== 'string' || + !/^\/api\/organizations\/e2e-org-1\/[a-zA-Z0-9_/-]+$/.test(fixture.path) || + next.has(`GET ${fixture.path}`) || + !Object.hasOwn(fixture, 'body') || + (fixture.query !== undefined && + (typeof fixture.query !== 'object' || + fixture.query === null || + Array.isArray(fixture.query) || + Object.values(fixture.query).some((value) => typeof value !== 'string'))) + ) + throw new Error( + 'Fixtures require distinct exact organization GET paths and queries.', + ); + next.set(`GET ${fixture.path}`, { + status: 200, + body: fixture.body, + query: fixture.query ?? {}, + }); + } + caseFixtures.clear(); + for (const [key, fixture] of next) caseFixtures.set(key, fixture); + response.end(JSON.stringify({ registered: caseFixtures.size })); + } catch (error) { + response.writeHead(422); + response.end(JSON.stringify({ title: error.message })); + } + }); + return; + } const routes = new Map([ [ 'POST /api/auth/refresh', @@ -119,6 +170,19 @@ function createApiStub(appOrigin, onShutdown) { 'organization.read', 'organization.webhooks.read', 'organization.automation.read', + 'organization.service_requests.read', + 'organization.service_requests.create', + 'organization.service_requests.manage', + 'organization.inventory.read', + 'organization.inventory.manage', + 'organization.inventory.consume', + 'organization.procurement.read', + 'organization.procurement.manage', + 'organization.maintenance_cost.read', + 'organization.maintenance_cost.manage', + 'organization.maintenance_exports.read', + 'organization.maintenance_exports.manage', + 'organization.maintenance_exports.confirm', ], }), ], @@ -177,13 +241,14 @@ function createApiStub(appOrigin, onShutdown) { [`GET ${org}/automation/runs`, hydraCollection([], { '@id': `${org}/automation/runs` })], ]; for (const [key, body] of fixtures) routes.set(key, { status: 200, body, allowQuery: true }); - routes.set('GET /.well-known/mercure', { status: 204, body: null, allowQuery: true }); + for (const [key, fixture] of caseFixtures) routes.set(key, fixture); + routes.set('GET /.well-known/mercure', { status: 200, body: null, allowQuery: true }); } if ( url.pathname === '/.well-known/mercure' && url.searchParams.get('authorization') === 'e2e-mercure-token' ) { - routes.set('GET /.well-known/mercure', { status: 204, body: null, allowQuery: true }); + routes.set('GET /.well-known/mercure', { status: 200, body: null, allowQuery: true }); } const method = request.method === 'OPTIONS' @@ -199,6 +264,13 @@ function createApiStub(appOrigin, onShutdown) { }); } const fixture = routes.get(`${method} ${url.pathname}`); + const exactQuery = + !fixture?.query || + (Object.keys(fixture.query).length === [...url.searchParams].length && + Object.entries(fixture.query).every( + ([key, value]) => + url.searchParams.getAll(key).length === 1 && url.searchParams.get(key) === value, + )); const entry = { method: request.method, path: url.pathname, @@ -207,7 +279,12 @@ function createApiStub(appOrigin, onShutdown) { origin: origin ?? null, status: fixture?.status ?? 501, }; - if (!fixture || (url.search && !fixture.allowQuery) || (origin && origin !== appOrigin)) { + if ( + !fixture || + !exactQuery || + (url.search && !fixture.allowQuery && !fixture.query) || + (origin && origin !== appOrigin) + ) { entry.status = 501; requests.push(entry); unexpected.push(entry); @@ -229,6 +306,13 @@ function createApiStub(appOrigin, onShutdown) { return; } requests.push(entry); + if (url.pathname === '/.well-known/mercure') { + response.setHeader('Content-Type', 'text/event-stream'); + response.writeHead(fixture.status); + response.flushHeaders(); + response.write(': connected\n\n'); + return; + } response.setHeader( 'Content-Type', fixture.status >= 400 ? 'application/problem+json' : 'application/ld+json', diff --git a/tests/e2e/ssr/api-stub.test.cjs b/tests/e2e/ssr/api-stub.test.cjs new file mode 100644 index 000000000..ff3b39a96 --- /dev/null +++ b/tests/e2e/ssr/api-stub.test.cjs @@ -0,0 +1,78 @@ +const assert = require('node:assert/strict'); +const { EventEmitter } = require('node:events'); +const { test } = require('node:test'); +const { createApiStub } = require('./api-stub.cjs'); +const { E2E_ACCESS_TOKEN } = require('../support/fixtures/api-fixtures.ts'); + +/** Exercises the stub handler without opening ports or running Angular. */ +function invoke(stub, method, url, { body, authenticated = false } = {}) { + return new Promise((resolve) => { + const request = new EventEmitter(); + request.method = method; + request.url = url; + request.headers = authenticated ? { authorization: 'Bearer ' + E2E_ACCESS_TOKEN } : {}; + request.setEncoding = () => {}; + const response = { + status: 200, + setHeader() {}, + writeHead(status) { + this.status = status; + return this; + }, + end(value) { + resolve({ status: this.status, body: value ? JSON.parse(value) : null }); + }, + }; + stub.handler(request, response); + if (body !== undefined) { + request.emit('data', JSON.stringify(body)); + request.emit('end'); + } + }); +} + +test('registered SSR fixtures require authentication and the complete exact query', async () => { + const stub = createApiStub('http://127.0.0.1:4274'); + const path = '/api/organizations/e2e-org-1/inventory-balances'; + const registration = await invoke(stub, 'POST', '/__harness/fixtures', { + body: { + fixtures: [ + { method: 'GET', path, query: { page: '1', itemsPerPage: '20' }, body: { member: [] } }, + ], + }, + }); + assert.deepEqual(registration, { status: 200, body: { registered: 1 } }); + assert.equal((await invoke(stub, 'GET', path + '?page=1&itemsPerPage=20')).status, 501); + assert.equal( + (await invoke(stub, 'GET', path + '?itemsPerPage=20&page=1', { authenticated: true })).status, + 200, + ); + const rejected = await Promise.all( + ['?page=1', '?page=1&page=1', '?page=1&itemsPerPage=20&warehouseId=other'].map((query) => + invoke(stub, 'GET', path + query, { authenticated: true }), + ), + ); + for (const response of rejected) assert.equal(response.status, 501); + assert.equal(stub.unexpected.length, 4); +}); + +test('fixture replacement is atomic and clearing it does not hide unknown-request evidence', async () => { + const stub = createApiStub('http://127.0.0.1:4274'); + const path = '/api/organizations/e2e-org-1/service-requests'; + await invoke(stub, 'POST', '/__harness/fixtures', { + body: { fixtures: [{ method: 'GET', path, body: { private: true } }] }, + }); + assert.equal( + ( + await invoke(stub, 'POST', '/__harness/fixtures', { + body: { fixtures: [{ method: 'GET', path: '/api/organizations/other/secret', body: {} }] }, + }) + ).status, + 422, + ); + assert.equal((await invoke(stub, 'GET', path, { authenticated: true })).status, 200); + await invoke(stub, 'POST', '/__harness/fixtures', { body: { fixtures: [] } }); + assert.equal((await invoke(stub, 'GET', path, { authenticated: true })).status, 501); + assert.equal(stub.unexpected.length, 1); + assert.equal(stub.unexpected[0].path, path); +}); diff --git a/tests/e2e/ssr/v2-private-routes.spec.ts b/tests/e2e/ssr/v2-private-routes.spec.ts new file mode 100644 index 000000000..8bd75d8d2 --- /dev/null +++ b/tests/e2e/ssr/v2-private-routes.spec.ts @@ -0,0 +1,385 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { expect, test, type APIRequestContext, type Browser } from '@playwright/test'; +import { maintenanceReportFixture } from '@features/organization/features/maintenance-costs/models/report/testing/maintenance-report.fixture'; +import { E2E_ACCESS_TOKEN, hydraCollection } from '../support/fixtures/api-fixtures'; +import { sourceFingerprint } from '../support/helpers/visual-run'; + +const appOrigin = 'http://127.0.0.1:4274'; +const apiOrigin = 'https://127.0.0.1:4275'; +const organizationPath = '/api/organizations/e2e-org-1'; +const interventionId = '12345678-1234-4234-8234-123456789abc'; +const privateMarker = 'SSR-private-maintenance-cost-731234'; +const run = process.env['FG_SSR_RUN'] ?? 'current'; +if (!/^[a-zA-Z0-9_-]+$/.test(run)) throw new Error('Invalid SSR run name.'); +const evidenceRoot = resolve('tests/e2e/artifacts/ssr-smoke', run); + +/** Requests recorded by the server-side local HTTPS stub. */ +interface RecordedRequest { + readonly method: string; + readonly path: string; + readonly query: string; + readonly status: number; + readonly origin: string | null; +} + +/** Every registered private fixture names one exact path and complete query. */ +interface PrivateFixture { + readonly method: 'GET'; + readonly path: string; + readonly query?: Readonly>; + readonly body: object; +} + +/** A private route and its only permitted initial business read. */ +interface PrivateScenario { + readonly name: string; + readonly path: string; + readonly title: string; + readonly settledText: string; + readonly settledTable?: string; + readonly fixture: PrivateFixture; + readonly visibleFilterFixtures?: readonly PrivateFixture[]; + readonly fixedTime?: string; +} + +/** Maintenance workspaces remain private when reached directly from an HTTP request. */ +const scenarios: readonly PrivateScenario[] = [ + { + name: 'economic-report', + path: 'maintenance-costs/reports', + title: 'Economic pilotage', + settledText: 'Entrance extinguisher — EX-001', + settledTable: 'Economic cost allocations', + fixedTime: '2026-10-07T12:00:00Z', + fixture: { + method: 'GET', + path: `${organizationPath}/maintenance-cost/reports`, + query: { + from: '2026-09-08', + to: '2026-10-07', + groupBy: 'equipment', + page: '1', + itemsPerPage: '30', + }, + body: { + ...maintenanceReportFixture({ + organizationId: 'e2e-org-1', + from: '2026-09-08', + to: '2026-10-07', + }), + privateMarker, + }, + }, + }, + { + name: 'maintenance-exports', + path: 'maintenance-exports', + title: 'Maintenance exports', + settledText: 'No retained export yet', + fixture: { + method: 'GET', + path: `${organizationPath}/maintenance-exports`, + query: { page: '1', itemsPerPage: '30' }, + body: { + ...hydraCollection([], { '@id': `${organizationPath}/maintenance-exports` }), + privateMarker, + }, + }, + visibleFilterFixtures: [ + { + method: 'GET', + path: `${organizationPath}/maintenance-export-references`, + query: { page: '1', itemsPerPage: '30' }, + body: { + ...hydraCollection([], { '@id': `${organizationPath}/maintenance-export-references` }), + privateMarker, + }, + }, + ], + }, + { + name: 'repair-requests', + path: 'service-requests', + title: 'Repair requests', + settledText: 'No maintenance requests', + fixture: { + method: 'GET', + path: `${organizationPath}/service-requests`, + query: { page: '1', itemsPerPage: '30' }, + body: { + ...hydraCollection([], { '@id': `${organizationPath}/service-requests` }), + privateMarker, + }, + }, + }, + { + name: 'inventory', + path: 'inventory/balances', + title: 'Stock', + settledText: 'No stock records in this view', + fixture: { + method: 'GET', + path: `${organizationPath}/inventory-balances`, + query: { page: '1', itemsPerPage: '20' }, + body: { + ...hydraCollection([], { '@id': `${organizationPath}/inventory-balances` }), + privateMarker, + }, + }, + visibleFilterFixtures: ['inventory-parts', 'inventory-warehouses'].map((endpoint) => ({ + method: 'GET', + path: `${organizationPath}/${endpoint}`, + query: { page: '1', itemsPerPage: '50', archived: 'false' }, + body: { + ...hydraCollection([], { '@id': `${organizationPath}/${endpoint}` }), + privateMarker, + }, + })), + }, + { + name: 'procurement', + path: 'procurement', + title: 'Purchasing and receipts', + settledText: 'No purchase orders match this view', + fixture: { + method: 'GET', + path: `${organizationPath}/procurement/orders`, + query: { page: '1', itemsPerPage: '30' }, + body: { + ...hydraCollection([], { '@id': `${organizationPath}/procurement/orders` }), + privateMarker, + }, + }, + }, + { + name: 'maintenance-costs', + path: `maintenance-costs?interventionId=${interventionId}`, + title: 'Maintenance costs', + settledText: privateMarker, + settledTable: 'Private maintenance cost contributions', + fixture: { + method: 'GET', + path: `${organizationPath}/interventions/${interventionId}/costs`, + body: { + '@id': `${organizationPath}/interventions/${interventionId}/costs`, + '@type': 'MaintenanceCost', + id: interventionId, + organizationId: 'e2e-org-1', + interventionId, + currency: 'EUR', + planningRevision: 0, + planningEditable: false, + plannedBudget: null, + estimatedMinutes: null, + resources: [], + current: { + total: '731234.567891', + knownTotal: '731234.567891', + complete: true, + items: [ + { + id: 'ssr-private-expense', + kind: 'expense', + sourceId: 'ssr-private-expense', + amount: '731234.567891', + currency: 'EUR', + description: privateMarker, + occurredAt: new Date().toISOString(), + }, + ], + }, + frozen: null, + }, + }, + }, +]; + +/** Identifies private maintenance families independently of the currently registered fixtures. */ +function isPrivateRead(entry: RecordedRequest): boolean { + return ( + entry.method === 'GET' && + /^\/api\/organizations\/[^/]+\/(?:service-requests|inventory-[^/]+|procurement|maintenance-cost|maintenance-exports|maintenance-export-[^/]+|interventions\/[^/]+\/costs)(?:\/|$)/.test( + entry.path, + ) + ); +} + +/** Reads cumulative requests without resetting evidence from earlier scenarios. */ +async function ledger(request: APIRequestContext): Promise<{ + requests: RecordedRequest[]; + unexpected: object[]; +}> { + return (await request.get(`${apiOrigin}/__harness/requests`)).json(); +} + +/** Replaces only case-specific exact paths; unknown routes keep the stub's failure ledger. */ +async function registerFixtures( + request: APIRequestContext, + fixtures: readonly PrivateFixture[], +): Promise { + const response = await request.post(`${apiOrigin}/__harness/fixtures`, { + data: { fixtures }, + }); + expect(response.status()).toBe(200); + expect(await response.json()).toEqual({ registered: fixtures.length }); +} + +/** Creates an authenticated browser context isolated from any real API or persistent session. */ +async function privateContext(browser: Browser) { + const context = await browser.newContext({ + baseURL: appOrigin, + ignoreHTTPSErrors: true, + serviceWorkers: 'block', + }); + await context.addCookies([ + { + name: 'refresh_token', + value: 'ssr-harness-session', + url: apiOrigin, + secure: true, + sameSite: 'None', + }, + ]); + await context.route('**/*', async (route) => { + const origin = new URL(route.request().url()).origin; + if ([appOrigin, apiOrigin].includes(origin)) return route.continue(); + await route.abort(); + expect.soft(origin, 'Private SSR checks must stay in the local harness.').toBe(appOrigin); + }); + return context; +} + +/** Checks the raw response before any browser JavaScript can access private collections. */ +function assertPrivateHtml(html: string): void { + expect(html).toContain(''); + expect(html).not.toMatch(/\sngh="/); + expect(html).not.toContain(E2E_ACCESS_TOKEN); + expect(html).not.toContain(privateMarker); + expect(html).not.toContain('731234.567891'); + const serializedState = html.match( + /