diff --git a/.env.example b/.env.example index a10f75c..0753eaa 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,12 @@ -# Local development only (`npm run dev`). Reference for the values -# `npm run dev` reads from .env; nothing here is used by a deployed build. -# -# In a deployed environment these values come from config.js, which the server -# writes when it places the build. Do not commit environment-specific values — -# one build is promoted across environments unchanged. -VITE_ZINO_API_URL=http://localhost:8085 -VITE_ZINO_MOCK=false +# Copy to .env.local and adjust. Vite only exposes vars prefixed with VITE_. + +# Auth host — POST /usr/login +VITE_API_BASE_URL=https://dev-apps.getzino.in + +# App/workflow host — POST /app/{APP_ID}/start +VITE_APP_API_BASE_URL=https://dev.getzino.in + +# Asset workflow — take workflow_uuid from the asset activity's sample cURL in +# the Zino editor. The uuid in the editor URL is a different id and is rejected. +# VITE_ASSET_WORKFLOW_UUID= +# VITE_ASSET_WORKFLOW_VERSION=1 diff --git a/.gitignore b/.gitignore index 5b08070..558d8b6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,8 @@ -node_modules/ -dist/ -.env +node_modules +dist +dist-ssr *.local +.vite +.DS_Store +npm-debug.log* +.env \ No newline at end of file diff --git a/Employee_data.postman_collection.json b/Employee_data.postman_collection.json new file mode 100644 index 0000000..f89f1cd --- /dev/null +++ b/Employee_data.postman_collection.json @@ -0,0 +1,133 @@ +{ + "info": { + "name": "Employee Data (Zino)", + "description": "All 5 APIs used by the Employee Data app. Run 'Login' first - it saves the token automatically for the other requests.", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "variable": [ + { "key": "auth_url", "value": "https://dev-apps.getzino.in" }, + { "key": "app_url", "value": "https://dev.getzino.in" }, + { "key": "token", "value": "" } + ], + "item": [ + { + "name": "1. Login", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "if (pm.response.code === 200) {", + " var t = pm.response.json().token;", + " pm.collectionVariables.set('token', t);", + " console.log('Token saved.');", + "} else {", + " console.log('Login failed:', pm.response.text());", + "}" + ] + } + } + ], + "request": { + "method": "POST", + "header": [{ "key": "Content-Type", "value": "application/json" }], + "url": { "raw": "{{auth_url}}/usr/login", "host": ["{{auth_url}}"], "path": ["usr", "login"] }, + "body": { + "mode": "raw", + "raw": "{\n \"email\": \"YOUR_EMAIL\",\n \"password\": \"YOUR_PASSWORD\"\n}" + } + } + }, + { + "name": "2. Employee List", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" }, + { "key": "Authorization", "value": "Bearer {{token}}" } + ], + "url": { "raw": "{{app_url}}/app/531/view/recordview", "host": ["{{app_url}}"], "path": ["app", "531", "view", "recordview"] }, + "body": { + "mode": "raw", + "raw": "{\n \"rv_template_uid\": \"f3b07dfd-ddcf-4039-ba82-078179a16f21\",\n \"preset_alias\": \"\",\n \"params\": {},\n \"search_query\": { \"page\": 1, \"limit\": 10, \"sort_by\": \"\", \"sort_dir\": \"\", \"search\": \"\", \"filters\": [] }\n}" + } + } + }, + { + "name": "3. Asset List", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" }, + { "key": "Authorization", "value": "Bearer {{token}}" } + ], + "url": { "raw": "{{app_url}}/app/531/view/recordview", "host": ["{{app_url}}"], "path": ["app", "531", "view", "recordview"] }, + "body": { + "mode": "raw", + "raw": "{\n \"rv_template_uid\": \"c91be68b-2650-44ef-b566-8f7ca3b85b28\",\n \"preset_alias\": \"\",\n \"params\": {},\n \"search_query\": { \"page\": 1, \"limit\": 10, \"sort_by\": \"\", \"sort_dir\": \"\", \"search\": \"\", \"filters\": [] }\n}" + } + } + }, + { + "name": "4. Create Employee", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" }, + { "key": "Authorization", "value": "Bearer {{token}}" } + ], + "url": { "raw": "{{app_url}}/app/531/start", "host": ["{{app_url}}"], "path": ["app", "531", "start"] }, + "body": { + "mode": "raw", + "raw": "{\n \"activity_id\": \"d42f026c-f9ac-4170-91c4-f1570d815d7a\",\n \"workflow_uuid\": \"f4a303c2-869a-47d5-a5f3-1d2f7d5ae58e\",\n \"version\": 1,\n \"data\": {\n \"field_1\": \"John\",\n \"field_34\": \"Doe\",\n \"field_2\": \"john.doe@example.com\",\n \"field_3\": 9876543210,\n \"field_4\": \"2026-09-23\",\n \"field_6\": \"permanent\",\n \"field_35\": \"Engineering\",\n \"field_36\": \"Developer\",\n \"field_37\": \"active\",\n \"field_38\": \"EMP001\"\n }\n}" + } + } + }, + { + "name": "5. Create Asset", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" }, + { "key": "Authorization", "value": "Bearer {{token}}" } + ], + "url": { "raw": "{{app_url}}/app/531/start", "host": ["{{app_url}}"], "path": ["app", "531", "start"] }, + "body": { + "mode": "raw", + "raw": "{\n \"activity_id\": \"a2405547-0db4-474b-aa76-af1246d01a6a\",\n \"workflow_uuid\": \"b5ea718c-e1a3-4d73-ac72-d0fc25f8b611\",\n \"version\": 1,\n \"data\": {\n \"field_43\": \"laptop\",\n \"field_42\": \"Dell Latitude 5440\",\n \"field_44\": \"SN123456\",\n \"field_45\": \"2026-09-01\",\n \"field_46\": 75000,\n \"field_47\": \"new\"\n }\n}" + } + } + }, + { + "name": "6. Update Employee", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" }, + { "key": "Authorization", "value": "Bearer {{token}}" } + ], + "url": { "raw": "{{app_url}}/app/531/activity", "host": ["{{app_url}}"], "path": ["app", "531", "activity"] }, + "body": { + "mode": "raw", + "raw": "{\n \"activity_id\": \"95d39136-519f-4558-95e4-df326f6fb68c\",\n \"data\": {\n \"field_9\": \"John\",\n \"field_8\": \"Doe\",\n \"field_10\": \"john.doe@example.com\",\n \"field_11\": 9876543210,\n \"field_5\": \"2026-09-23\",\n \"field_7\": \"permanent\",\n \"field_12\": \"Engineering\",\n \"field_13\": \"Senior Developer\",\n \"field_14\": \"active\",\n \"field_15\": \"EMP001\"\n },\n \"instance_id\": \"INSTANCE_ID_FROM_INIT\",\n \"workflow_uuid\": \"f4a303c2-869a-47d5-a5f3-1d2f7d5ae58e\"\n}" + } + } + }, + { + "name": "7. Mark Employee Inactive", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/json" }, + { "key": "Authorization", "value": "Bearer {{token}}" } + ], + "url": { "raw": "{{app_url}}/app/531/activity", "host": ["{{app_url}}"], "path": ["app", "531", "activity"] }, + "body": { + "mode": "raw", + "raw": "{\n \"activity_id\": \"7dcde7c9-82ce-4dd5-bcca-68fca6904eb4\",\n \"data\": {\n \"field_16\": \"2026-09-25\",\n \"field_33\": \"Reason for leaving\",\n \"field_39\": { \"id\": 8 },\n \"field_40\": { \"id\": 8, \"employee_code\": \"7656565\" }\n },\n \"instance_id\": \"INSTANCE_ID_FROM_INIT\",\n \"workflow_uuid\": \"f4a303c2-869a-47d5-a5f3-1d2f7d5ae58e\"\n}" + } + } + } + ] +} diff --git a/asset-lifecycle-diagram.png b/asset-lifecycle-diagram.png new file mode 100644 index 0000000..cfc6035 Binary files /dev/null and b/asset-lifecycle-diagram.png differ diff --git a/docs/asset-assignment-workflow.html b/docs/asset-assignment-workflow.html new file mode 100644 index 0000000..27709fc --- /dev/null +++ b/docs/asset-assignment-workflow.html @@ -0,0 +1,221 @@ + + + + +Asset Assignment Workflow — states and activities + + + +
+
+

Asset Assignment Workflow

+

States an Assigned Asset can be in, and the activities the Asset Manager, the System and the Employee perform to move it between them.

+
+
+ ASSET STATE + Unassigned + Assigned + Pending acknowledgement + Deleted +
+
+ +

States · boxes are the four states, arrows are the activities that change state

+ + + + + + + + + Asset registered + asset tag issued + + + + Unassigned + in stock · no employee + + + + Assign + manager picks employee + + + + Pending ack. + waiting for employee + + + + Acknowledge + employee accepts + + + + Declined / no reply in 3 days + + + + Assigned + Employee Name · Employee ID + assigned on · by Asset Manager + + + + Reassign + new Employee Name / ID · both notified · stays Assigned + + + + Delete · reason = Returned to stock + asset removed from employee, back in pool + + + + Delete + Lost · Damaged + Retired + + + + + Deleted + soft · kept in log + + +

Activities · who does what, in order, and the asset state after each step

+
+
LANE
+
1Find employee
+
2View assigned assets
+
3Choose activity
+
4Fill details
+
5Confirm & apply
+
6Close out
+ + +
Asset Managerowns every decision
+
Search employeeby Employee Name or Employee ID
+
Open profilereads Name · ID · Assigned Asset list
+
+
ASSIGNClick “Assign asset”button on the profile header
+
REASSIGNClick “Reassign” on an asset row
+
DELETEClick “Delete” on an asset row
+
+
+
Pick asset from poolonly Unassigned assets offered · assigned-on date · condition
+
Pick new employeesearch Name / ID · must be active · transfer date
+
Pick a reasonReturned to stock · Lost · Damaged · Retired
+
+
+
Confirm assignmentsummary: asset → employee
+
Confirm transfersummary: from → to · date
+
Type asset tag to confirmguards against accidental delete
+
+
Sees refreshed profileupdated Assigned Asset list + history entry
+ + +
Systemvalidates · writes · notifies
+
Match employeeexact ID or name search
+
Load assigned assetstype · tag · assigned on · assigned by
+
Show only valid actionsReassign / Delete only on assets in state Assigned
+
+
ASSIGNValidate asset is Unassigned
+
REASSIGNValidate new employee is activeand not the current holder
+
DELETERequire reason
+
+
+
ASSIGNSet Pending ack. · notify employee
+
REASSIGNMove asset to new employeenever unassigned in between · notify both
+
DELETERemove from employeeReturned → Unassigned · else → Deleted
+
+
Write history entrywho · what · when · reason · from → to. Append only, never edited.
+ + +
EmployeeName · ID on record
+
—
+
—
+
—
+
—
+
+
ASSIGNAcknowledge receiptemail / in-app · Pending → Assigned
+
REASSIGNOld holder hands over · new holder acknowledges
+
DELETEGets notifiednothing to do
+
+
Sees own asset list updatedin employee view
+ + +
Asset stateafter the step
+
Assigned unchanged
+
Assigned unchanged
+
Assigned unchanged
+
Assigned unchanged
+
+ ASSIGNPending ack. + REASSIGNAssigned new holder + DELETEUnassigned returned + DELETEDeleted lost · damaged · retired +
+
+ ASSIGNAssigned after ack. + REASSIGNAssigned + DELETEUnassigned + DELETEDeleted terminal +
+
+ + + + diff --git a/docs/asset-assignment-workflow.png b/docs/asset-assignment-workflow.png new file mode 100644 index 0000000..6bcc7c0 Binary files /dev/null and b/docs/asset-assignment-workflow.png differ diff --git a/index.html b/index.html index 0a97ae2..f02eb79 100644 --- a/index.html +++ b/index.html @@ -3,12 +3,10 @@ - - asset-management - + Office Asset Management
- + - \ No newline at end of file + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..c1bff43 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1740 @@ +{ + "name": "office-asset-management", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "office-asset-management", + "version": "0.1.0", + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.26.2" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.1", + "vite": "^5.4.8" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.4", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.4.tgz", + "integrity": "sha512-q7j5geK7xs3UJSdm9/iytUNclBnLmYx1EnSeCFXHPeutdqgIMeFeHtUZgS3EhlKxdBEAu8OwtJCwmLrEzpSs7Q==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.3.tgz", + "integrity": "sha512-w3Jnvi1ocaVm/c7yVPpfB98XeSRBMyzp6njL5MVVbGyXjpmUkN+s6Hp4t0PqhGCCaI1ZHMKXt/w0lA1RCaLVcw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.3.tgz", + "integrity": "sha512-uI/ESiaIbbRYAEhzy8PCUWDp1hB0bjAqM06mW9flOoNO4Q8DQpeoREhBR5Hegfl+wpXiguyJv6XSPzEN7OxyHQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.3.tgz", + "integrity": "sha512-oxhrd1jmXLwWZ83eQYDXxuqRdkqkzrjR3JobKeuUyfdNZo11FuQIvqEOZhyIT7OBHxXoGslDDjN0cQcM6T0TqQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.3.tgz", + "integrity": "sha512-7/YiIMghVE8DrxKvNdorAaJVdriOFgOIpdStnPx8ppx5zfTwC3jBCSEAIzB7JD5404m65THl6H93UTTVUvypmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.3.tgz", + "integrity": "sha512-GXFZRRoMAytaI5z6N3Zhfw0WL18Q0M8r95D5hlC4GqE/lGk8pbSJNUBoOWDfbm6dTciqHj2nU87tI5f6XhQiOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.3.tgz", + "integrity": "sha512-77W+8X3ddYgPxUpB8nZFQs2Mq+wc4HVlcSRtApXLjYBcnPMkttrSnU8VwKQjeWYhMsITHFs5cWBQ8vz1Q+5RHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.3.tgz", + "integrity": "sha512-FVkwK+iUC+mq+GipVK46rRVticfAPtvPUNlqlGXUDxdVk/UGjQiiiUVPUrEXdSpU2ufU0XxLGyTqDtBidDOVmg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.3.tgz", + "integrity": "sha512-+aGU1t3398yQOVj1Bz8o3e+KtswxAPvO+mtxtNdfXYMkXIHu7XhhkCD7/DEH9q8tF8uhDnMWvfpUKI8y1sZJsg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.3.tgz", + "integrity": "sha512-cR0kjpRXR2KJ2oQK8E2KTPtphs+b9hZ8IhTZubNryt/RsqgdOZBQ2Zq0q5UedtiIi0rs3jVhJh55RE1ZHUVGUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.3.tgz", + "integrity": "sha512-y1RYi4Q3/9ByVWSSt9kX2ustE0B7kFYbJ6zZdVZVyqopZs3yhCTwRfrjIX4vezUJInma/Gs6BOFDJg7yZmJ0IQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.3.tgz", + "integrity": "sha512-DNhEA5viIj3Z5bZLE4z4oV8N5ozWqDwyt7T6KG7VdLDJ0nW+rNOYlphBl4/3HQkK75qipPLsVOfStHHOwN9WSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.3.tgz", + "integrity": "sha512-17gQCqrIpXBX2Cmi9/TygnVOqGbzsba/iaqcYSL8FY7lNugg+7AiYNs5c5nKWD+NRQha36Sa0CqkJqH4XVHwnQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.3.tgz", + "integrity": "sha512-6LwVnZRIyINpdku/yOcI8Tm9YqLmhHK5emmlOOnW9tO0SYEm1FmKPcsSAGp0NBlqR2P04xaND4jvN6sTHqhq8A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.3.tgz", + "integrity": "sha512-xMUqkTXlEUtI/p5AAukMwBRr1enU3efsTeF+bskeFfk8t1C9rcC8sLREcZXmTfAXEbvRdJVSonVJez3TMlbR3w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.3.tgz", + "integrity": "sha512-S3E94co9F9WRRqEaUoQZ38K1gCz6KiM+nL7/3ijq7fDGF3OznjS5TasgYITlvl27GQKtu4lOAOsr5MFwkijvOA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.3.tgz", + "integrity": "sha512-1QtRDwG42x5BJI3s9mxu5rEjDnfbSnk20HQ9/ylTAYnSwYwxMVb+Vgu34wzzTQ7ogqBybebgQNUDAvZVQ38DbA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.3.tgz", + "integrity": "sha512-BQhejF6ZXOpxbngiNTP12GCGQeaDVL2QXGeBVViKIYzFHM5RKxTxwUMB1fr1BeNFphFMpnRqC5QSXFSa4z6UQw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.3.tgz", + "integrity": "sha512-SXagRwnI2Wlwlitllu59UK/nGVbD1CKPcNqDplHwIC4BqJcpXFjD32d1R/RbuISa95HdQrZM3/7v4bKiowFaLA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.3.tgz", + "integrity": "sha512-2IPozoEALRCziGqE8O9KMK60PMu5TS1huv4fwoeCexj+WjmcwFtX9CTOVbfXCUqcELAubEwRFPYlzb/WvwY2HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.3.tgz", + "integrity": "sha512-AoxqosUHT9IX54hFn2TiN6A7d6ZKTtE6pd2bqWtqkkNJ6HJGaU6FRouGX8L1O7R/ZwsnCnpQrHzb4pDEx+UHRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.3.tgz", + "integrity": "sha512-d+CaftKgmkFBzCwezMqqy1d0QNNYugqLCMcYVQWBy5SS2YfeMP8Q8ripkgx9O8IyBXXLHrJ+aaCV4U96usv6Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.3.tgz", + "integrity": "sha512-xXlDF6nR1eOuXbdDy5Hl5fmtY7teUDevF/k0O7IPoZe4Tpmdv+lgdE5JRsnhQtt37ql9P0VF2kAN9a0OCZdo+Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.3.tgz", + "integrity": "sha512-YtXAgLN+JP7Ay6qG3eWhc7IHMQPzLc8r3uvhAvlJIoCz/4Q32+Bl9Fmnywidh8v1GOIMmymjovfqY9ETAtysvA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.3.tgz", + "integrity": "sha512-WuWtSJRNo549vzcfZyEgfqb6zeSgn1F+UE5kQ+BCjzz0W4MGCjntUHkZVc1VRuAM7+ULaSyhiPxD1spyewFvkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.3.tgz", + "integrity": "sha512-+lIKX7O0+IGe7WuhATaAMMeT7B76vfhXH/l9wLQL+nvyhbw2ohYCKIdWL56JfDu75CWt5oKRP4QFH/jkMtBquA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.23", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.23.tgz", + "integrity": "sha512-le521dGVfxM7yRX0EikCoSz+rOK+hHzdDt/E7mG1jOJB/6WAAUuwVroLwaB7ApaUsz5Q0kFlDXLSA9MheUIfRQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.9", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.9.tgz", + "integrity": "sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.20", + "caniuse-lite": "^1.0.30001810", + "electron-to-chromium": "^1.5.420", + "node-releases": "^2.0.54", + "update-browserslist-db": "^1.3.2" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.428", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.428.tgz", + "integrity": "sha512-1JxbaFJj1bRKurj1uY3l4xxpU9kOUAUjcIgApj0qu1Pao5GhoIWI8iL0BeMYJ2njig1hBx0A7eKD9VGjH9wlHw==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.55", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.55.tgz", + "integrity": "sha512-mIrE/Cw9y+9Au6dS5vDKDhQza9YvG6w+ZrS6X+ZzA7yFW/soAeaups4Qzn1bL6g5FVy8WtP79+0j82oPIbqRjQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.6", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.6.tgz", + "integrity": "sha512-5HfK7k5im7LTOB0EqCQmfvy4C13G92Ssj1VTmouTK3AJvyjKTnFuCV0vcMAD/JS+JC4DvDIBRrlAeJIFjh5VWg==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.4" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.6", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.6.tgz", + "integrity": "sha512-0RHKZz7wwffvkU+2MFVT2NnjK44ssLEV+m0CAJaS2Ksmorrwj7WxH00jO0SOCW26/tINUnJHToXblDs33I38YQ==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.4", + "react-router": "6.30.6" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/rollup": { + "version": "4.63.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.3.tgz", + "integrity": "sha512-1i2XreiAoMMXuPGD6Msj2xWrMMkHojNRKivInxGQcg7/1KuPuYlfUutLyh4drnOxUTHX9cHI4wFoat8D/NKaBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.3", + "@rollup/rollup-android-arm64": "4.63.3", + "@rollup/rollup-darwin-arm64": "4.63.3", + "@rollup/rollup-darwin-x64": "4.63.3", + "@rollup/rollup-freebsd-arm64": "4.63.3", + "@rollup/rollup-freebsd-x64": "4.63.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.3", + "@rollup/rollup-linux-arm-musleabihf": "4.63.3", + "@rollup/rollup-linux-arm64-gnu": "4.63.3", + "@rollup/rollup-linux-arm64-musl": "4.63.3", + "@rollup/rollup-linux-loong64-gnu": "4.63.3", + "@rollup/rollup-linux-loong64-musl": "4.63.3", + "@rollup/rollup-linux-ppc64-gnu": "4.63.3", + "@rollup/rollup-linux-ppc64-musl": "4.63.3", + "@rollup/rollup-linux-riscv64-gnu": "4.63.3", + "@rollup/rollup-linux-riscv64-musl": "4.63.3", + "@rollup/rollup-linux-s390x-gnu": "4.63.3", + "@rollup/rollup-linux-x64-gnu": "4.63.3", + "@rollup/rollup-linux-x64-musl": "4.63.3", + "@rollup/rollup-openbsd-x64": "4.63.3", + "@rollup/rollup-openharmony-arm64": "4.63.3", + "@rollup/rollup-win32-arm64-msvc": "4.63.3", + "@rollup/rollup-win32-ia32-msvc": "4.63.3", + "@rollup/rollup-win32-x64-gnu": "4.63.3", + "@rollup/rollup-win32-x64-msvc": "4.63.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz", + "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/package.json b/package.json index 2cc4618..57e9559 100644 --- a/package.json +++ b/package.json @@ -1,29 +1,20 @@ { - "name": "asset-management", + "name": "office-asset-management", "private": true, "version": "0.1.0", "type": "module", "scripts": { "dev": "vite", "build": "vite build", - "typecheck": "tsc -b", "preview": "vite preview" }, "dependencies": { - "@tanstack/react-query": "^5.60.5", - "lucide-react": "^0.441.0", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-router-dom": "^6.26.0" + "react-router-dom": "^6.26.2" }, "devDependencies": { - "@types/react": "^18.3.5", - "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.1", - "autoprefixer": "^10.4.20", - "postcss": "^8.4.41", - "tailwindcss": "^3.4.10", - "typescript": "~5.5.4", - "vite": "^5.4.2" + "vite": "^5.4.8" } -} \ No newline at end of file +} diff --git a/src/App.jsx b/src/App.jsx new file mode 100644 index 0000000..90bdd01 --- /dev/null +++ b/src/App.jsx @@ -0,0 +1,36 @@ +import { Navigate, Route, Routes } from 'react-router-dom' +import AppLayout from './components/layout/AppLayout' +import RequireAuth from './components/auth/RequireAuth' +import Login from './pages/auth/Login' +import EmployeeList from './pages/employees/EmployeeList' +import EmployeeDetails from './pages/employees/EmployeeDetails' +import AssetList from './pages/assets/AssetList' +import AssetDetails from './pages/assets/AssetDetails' +import AssetActivityList from './pages/assets/AssetActivityList' +import Dashboard from './pages/Dashboard' + +export default function App() { + return ( + + } /> + + }> + }> + } /> + + } /> + } /> + + } /> + } /> + {/* Keyed so moving between the lists starts fresh (filter, page). */} + } /> + } /> + } /> + + } /> + + + + ) +} diff --git a/src/api/activityForm.js b/src/api/activityForm.js new file mode 100644 index 0000000..74bba86 --- /dev/null +++ b/src/api/activityForm.js @@ -0,0 +1,119 @@ +/** + * Helpers shared by every Zino activity form. A field definition mirrors what + * the builder stores: + * { id, label, type, options?, computed?, maxSizeMb?, slots? } + * where `type` is the builder's data_type: text, email, number, date, select, + * longtext, geolocation, image, id_gen. `slots` (image only) names one photo + * per view, e.g. ['Front', 'Back', 'Side']; `otherSlot` names a box for any + * number of extra photos. + */ + +/** + * Turn a raw backend error into something actionable. + * + * The runtime validates against the *deployed* snapshot of the app, not the + * builder draft. Fields added in the builder after the last deploy come back + * as "field_NN(unknown)" until the app is deployed again. + */ +export function explainActivityError(err) { + const message = err?.message || '' + const unknown = [...message.matchAll(/(\w+)\(unknown\)/g)].map((m) => m[1]) + if (unknown.length) { + err.message = + `The workflow does not recognise ${unknown.join(', ')}. ` + + 'These fields exist in the Zino builder but are not in the deployed app. ' + + 'Deploy the app in Zino, then try again.' + } + // The activity id isn't one of the instance's state's activities. Either the + // workflow already finished (End State), or the id belongs to another + // activity — several in one version share similar names (Resign, Terminate…). + const state = message.match(/activity not allowed in state (.+)$/i)?.[1]?.trim() + if (state) { + err.message = /end/i.test(state) + ? `This record is in "${state}": its workflow has already finished, so no further activities can run on it.` + : `This activity is not one of the "${state}" state's activities. ` + + 'Check that the activity id in the app matches the one in that state in the Zino builder.' + } + return err +} + +/** A workflow instance's current state name, when the runtime returns one. */ +export function instanceState(record) { + const v = record?.current_state_name ?? record?.state_name ?? record?.current_state ?? record?.state + return typeof v === 'string' ? v.trim() : '' +} + +/** True once an instance has reached its end state — no activity can run on it. */ +export const isEndState = (record) => /^end(\s|_)?state$|^end$/i.test(instanceState(record)) + +/** Blank form state for a field list. */ +export function emptyValues(fields) { + return Object.fromEntries(fields.filter((f) => !f.computed).map((f) => [f.id, ''])) +} + +/** + * Build the `data` object for the start endpoint. Computed fields (id_gen) are + * generated by the backend and never sent, numbers are coerced, and empty + * values are dropped so the backend never sees "" for a number or date. + */ +export function buildActivityData(fields, values) { + const data = {} + for (const field of fields) { + if (field.computed) continue + const raw = values[field.id] + if (raw === '' || raw === null || raw === undefined) continue + + if (field.type === 'number') { + const n = Number(String(raw).replace(/[^\d.-]/g, '')) + if (Number.isFinite(n)) data[field.id] = n + continue + } + // Photo slots ({ Front, Back, …, Other: [...] }) go out as one list, in + // slot order, with the other photos last. + if (field.type === 'image' && field.slots?.length) { + const images = [ + ...field.slots.map((slot) => raw[slot]), + ...(field.otherSlot ? raw[field.otherSlot] ?? [] : []), + ].filter(Boolean) + if (images.length) data[field.id] = images + continue + } + data[field.id] = typeof raw === 'string' ? raw.trim() : raw + } + return data +} + +/** + * The newest value per label in a workflow record. Activities store the same + * label under different ids (INIT's field_2 and Update's field_10 are both + * "email"), so list `fields` oldest activity first: the last non-empty wins. + * @returns {Map} label -> value + */ +export function latestByLabel(fields, record) { + const out = new Map() + for (const field of fields) { + const v = record?.[field.id] + if (v !== undefined && v !== null && v !== '') out.set(field.label, v) + } + return out +} + +/** Per-field format checks. Nothing is required — no builder field is mandatory. */ +export function validateActivity(fields, values) { + const errors = {} + for (const field of fields) { + const raw = values[field.id] + if (!raw) continue + + if (field.type === 'email' && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(raw).trim())) { + errors[field.id] = 'Enter a valid email address.' + } + if (field.type === 'number') { + const n = Number(String(raw).replace(/[^\d.-]/g, '')) + if (!Number.isFinite(n)) errors[field.id] = 'Enter a number.' + // Past this, JS rounds the value (e.g. 8.95e+26), so what we'd send is not + // what was typed — and it would overflow the backend's column anyway. + else if (Math.abs(n) > Number.MAX_SAFE_INTEGER) errors[field.id] = 'This number is too long.' + } } + return errors +} diff --git a/src/api/assets.js b/src/api/assets.js new file mode 100644 index 0000000..d2847fb --- /dev/null +++ b/src/api/assets.js @@ -0,0 +1,821 @@ +import { APP_API_BASE_URL, apiFetch } from './client' +import { buildActivityData, explainActivityError, instanceState } from './activityForm' +import { fetchEmployeeListPage } from './employees' +import { RECORD_VIEWS, fetchAllRecordRows, toISODate } from './recordView' +import { DETAIL_VIEWS, fetchDetailView, normLabel, normalizeDetailView } from './detailView' +import { HISTORY_ACTION } from '../constants' + +/** + * Asset onboarding is a second Zino workflow in the same app. Creating an + * asset means starting that workflow's INIT activity with the form data. + * + * POST {APP_API_BASE_URL}/app/{APP_ID}/start + * Authorization: Bearer + * { activity_id, workflow_uuid, version, + * data: { field_42 … field_58, field_59: { id, first_name } } } + * + * NOTE ON IDS + * 762affb9-… in the editor URL is the workflow *version* uid. The workflow + * uuid the start endpoint wants is b5ea718c-… (from GET /api/apps/531/workflows, + * where this workflow is named "asset workflow"). + */ + +export const ASSET_APP_ID = '531' +export const ASSET_WORKFLOW_UUID = 'b5ea718c-e1a3-4d73-ac72-d0fc25f8b611' +export const ASSET_VERSION_UID = '762affb9-ed54-40e5-9958-b27ff6772893' +export const ASSET_INIT_ACTIVITY_ID = 'a2405547-0db4-474b-aa76-af1246d01a6a' +export const ASSET_WORKFLOW_VERSION = 1 + +/** + * INIT activity fields, exactly as configured in the Zino builder — same ids, + * labels, types, option values and order. + * Source: GET /api/apps/531/versions/{ASSET_VERSION_UID}/activities/{id}/data-fields + */ +export const ASSET_FIELDS = [ + { id: 'idgen_1', label: 'Asset ID', type: 'id_gen', computed: true }, + { + id: 'field_43', + label: 'Category', + type: 'select', + options: [ + { label: 'Desktop', value: 'desktop' }, + { label: 'Monitor', value: 'monitor' }, + { label: 'Laptop', value: 'laptop' }, + { label: 'Mobile phone', value: 'mobile_phone' }, + { label: 'Tablet', value: 'tablet' }, + { label: 'Accessories', value: 'accessories' }, + { label: 'Networking', value: 'networking' }, + { label: 'Printer', value: 'printer' }, + ], + }, + { id: 'field_42', label: 'Model', type: 'text' }, + { id: 'field_44', label: 'Serial number', type: 'text' }, + { id: 'field_45', label: 'Purchase date', type: 'date' }, + { id: 'field_46', label: 'Purchase cost', type: 'number' }, + { + id: 'field_47', + label: 'Condition', + type: 'select', + options: [ + { label: 'new', value: 'new' }, + { label: 'damage', value: 'damage' }, + ], + }, + // One photo per view, then any number of others; sent as one list in this order. + { + id: 'field_48', + label: 'image', + type: 'image', + maxSizeMb: 5, + slots: ['Front', 'Back', 'Side'], + otherSlot: 'Other', + }, + { id: 'field_50', label: 'CPU', type: 'text' }, + { id: 'field_51', label: 'Warranty status', type: 'text' }, + { id: 'field_52', label: 'Hard Drive', type: 'text' }, + // No options are configured for this select in the builder. + { id: 'field_53', label: 'New Activity Field', type: 'select', options: [] }, + { id: 'field_54', label: 'Warranty Start date', type: 'date' }, + { id: 'field_55', label: 'Warrenty End date', type: 'date' }, + // Only the first option of each select below is known (from the API sample) — + // copy the rest from the builder. + { + id: 'field_56', + label: 'Asset State', + type: 'select', + options: [{ label: 'In use', value: 'in_use' }], + }, + { + id: 'field_57', + label: 'Asset Classification', + type: 'select', + options: [{ label: 'Internal', value: 'internal' }], + }, + { + id: 'field_58', + label: 'Asset Priority', + type: 'select', + options: [{ label: 'Medium', value: 'medium' }], + }, +] + +/** + * "Asset Owner": a lookup into the employee table, like ASSIGNED's "Employee + * data". The API sample shows only { id }; it is sent as { id, first_name } + * because the runtime checks lookups against their storage columns. + */ +export const ASSET_OWNER_FIELD = 'field_59' + +/** + * Start the asset INIT activity. `owner` is one entry from + * fetchAssignableEmployees(), or null to leave "Asset Owner" out. + * Resolves with the backend response. + */ +export async function createAsset(values, owner = null) { + try { + return await apiFetch(`${APP_API_BASE_URL}/app/${ASSET_APP_ID}/start`, { + method: 'POST', + body: { + activity_id: ASSET_INIT_ACTIVITY_ID, + workflow_uuid: ASSET_WORKFLOW_UUID, + version: ASSET_WORKFLOW_VERSION, + data: { + ...buildActivityData(ASSET_FIELDS, values), + ...(owner ? { [ASSET_OWNER_FIELD]: employeeLookup(owner) } : {}), + }, + }, + }) + } catch (err) { + throw explainActivityError(err) + } +} + +/** + * The "ASSIGNED" activity in the Available state of the asset workflow. It + * hands an existing asset to an employee and moves it to the Assigned state. + * Unlike INIT (which uses /start), it posts to /activity with the asset's + * instance_id. + * + * POST {APP_API_BASE_URL}/app/{APP_ID}/activity + * Authorization: Bearer + * { activity_id, workflow_uuid, instance_id, + * data: { field_1, field_5, field_6, field_21, field_22: { id, first_name } } } + * + * NOTE ON IDS + * The activity id is the one in the builder URL (…/activities/d18815a4-…). + * 119d494a-… is the Retired activity in the same state — its lookup is + * "Retired by" — so don't use it here: running it closes the asset for good. + * Source: the asset workflow "ASSIGNED" activity, Fields tab, in the Zino builder. + */ +export const ASSET_ASSIGN_ACTIVITY_ID = 'd18815a4-4e74-480d-a943-67dce894412f' + +/** Fields the user fills in. */ +export const ASSET_ASSIGN_FIELDS = [ + { id: 'field_1', label: 'Assigned date', type: 'date' }, + // Options for both selects aren't known yet — copy them from the builder. + { id: 'field_5', label: 'Assigned by', type: 'select', options: [] }, + { id: 'field_6', label: 'Remarks', type: 'longtext' }, + { id: 'field_21', label: 'department', type: 'select', options: [] }, +] + +/** + * "Employee data": a lookup into the employee table. The runtime checks it + * against the lookup's storage columns (a Retired attempt failed with + * "missing required storage column 'first_name'"), so it carries + * { id, first_name }, not the { id, name } the API sample shows. It is picked + * from a list of active employees, so it is sent alongside the form data. + */ +export const ASSIGN_EMPLOYEE_FIELD = 'field_22' + +/** + * Active employees an asset can be assigned to, as { id, first_name, name, code }. + * `id` is the asset_management.employees bigint id the lookup expects. + */ +export async function fetchAssignableEmployees() { + const res = await fetchEmployeeListPage(RECORD_VIEWS.employees.templateUid, { + filter: 'active', + limit: 1000, + }) + return res.data + .filter((r) => r.id != null && r.id !== '') + .map((r) => ({ + id: Number(r.id), + first_name: String(r.first_name ?? ''), + name: + [r.first_name, r.last_name].filter(Boolean).join(' ').trim() || + String(r.employee_code || r.email || `Employee ${r.id}`), + code: r.employee_code ?? '', + })) +} + +/** + * The "Reassigned" activity in the Assigned state of the asset workflow. It + * hands an assigned asset to another employee; the asset stays Assigned. + * Like ASSIGNED it posts to /activity with the asset's instance_id. + * + * POST {APP_API_BASE_URL}/app/{APP_ID}/activity + * Authorization: Bearer + * { activity_id, workflow_uuid, instance_id, + * data: { field_4, field_11, field_12, field_23: { id, first_name } } } + * + * Source: the asset workflow "Reassigned" activity, Fields tab, in the Zino + * builder (…/activities/db4463c4-…). + */ +export const ASSET_REASSIGN_ACTIVITY_ID = 'db4463c4-033b-496d-9d21-88a6e5159028' + +/** Fields the user fills in. */ +export const ASSET_REASSIGN_FIELDS = [ + { id: 'field_4', label: 'Assigned date', type: 'date' }, + // Options aren't known yet — copy them from the builder. + { id: 'field_11', label: 'Assigned by', type: 'select', options: [] }, + { id: 'field_12', label: 'Remarks', type: 'longtext' }, +] + +/** Its own "Employee data" lookup — a different id from ASSIGNED's field_22. */ +export const REASSIGN_EMPLOYEE_FIELD = 'field_23' + +/** + * The "RETURNED" activity in the Assigned state of the asset workflow. It takes + * an asset back from its employee and moves it to Available again. Like + * ASSIGNED it posts to /activity with the asset's instance_id. + * + * POST {APP_API_BASE_URL}/app/{APP_ID}/activity + * Authorization: Bearer + * { activity_id, workflow_uuid, instance_id, + * data: { field_8, field_9, field_10, field_29, field_28: { id, first_name } } } + * + * Source: the asset workflow "RETURNED" activity, Fields tab, in the Zino + * builder (…/activities/7f7b43fb-…). + */ +export const ASSET_RETURN_ACTIVITY_ID = '7f7b43fb-557c-483a-97a5-09b0a8080622' + +/** Fields the user fills in. */ +export const ASSET_RETURN_FIELDS = [ + { id: 'field_8', label: 'Returned date', type: 'date' }, + // Options aren't known yet — copy them from the builder. + { id: 'field_29', label: 'Condition', type: 'select', options: [] }, + { id: 'field_9', label: 'Damage details', type: 'longtext' }, + { id: 'field_10', label: 'Remarks', type: 'longtext' }, +] + +/** "Returned by": a lookup into the employee table, like "Employee data". */ +export const RETURN_EMPLOYEE_FIELD = 'field_28' + +/** + * The "Lost" activity of the asset workflow. It records a lost asset and moves + * it to Closed, which has no activities — the asset can't be used again. The + * diagram offers it from Assigned (and lists it in Available); the runtime + * rejects it in any state that doesn't have it. + * + * POST {APP_API_BASE_URL}/app/{APP_ID}/activity + * Authorization: Bearer + * { activity_id, workflow_uuid, instance_id, + * data: { field_36 … field_41, field_35: { id, first_name } } } + * + * Source: the asset workflow "Lost" activity, Fields tab, in the Zino builder + * (…/activities/5e9b6f96-…). + */ +export const ASSET_LOST_ACTIVITY_ID = '5e9b6f96-24d2-4412-a41c-dad21e6ae70c' + +/** Fields the user fills in. */ +export const ASSET_LOST_FIELDS = [ + { id: 'field_36', label: 'Date reported', type: 'date' }, + // Options for both selects aren't known yet — copy them from the builder. + { id: 'field_37', label: 'Type', type: 'select', options: [] }, + { id: 'field_39', label: 'Reference number', type: 'number' }, + { id: 'field_40', label: 'Recovery cost', type: 'number' }, + { id: 'field_41', label: 'Manager name', type: 'select', options: [] }, + { id: 'field_38', label: 'Remarks', type: 'longtext' }, +] + +/** "Last owner": a lookup into the employee table, like "Employee data". */ +export const LOST_EMPLOYEE_FIELD = 'field_35' + +/** An employee lookup value, in the shape the runtime validates. */ +const employeeLookup = (employee) => ({ id: employee.id, first_name: employee.first_name }) + +/** + * Run a later activity (ASSIGNED, RETURNED, …) on an existing asset instance. + * `extra` is merged into `data` as-is, for values the form doesn't hold. + */ +async function runAssetActivity(activityId, fields, instanceId, values, extra = {}) { + try { + return await apiFetch(`${APP_API_BASE_URL}/app/${ASSET_APP_ID}/activity`, { + method: 'POST', + body: { + activity_id: activityId, + data: { ...buildActivityData(fields, values), ...extra }, + // The runtime decodes this as int64, so "58950" (e.g. from a URL) is rejected. + instance_id: /^\d+$/.test(String(instanceId)) ? Number(instanceId) : instanceId, + workflow_uuid: ASSET_WORKFLOW_UUID, + }, + }) + } catch (err) { + throw explainActivityError(err) + } +} + +/** + * Run the ASSIGNED activity on an existing asset instance (the one created by + * INIT). `employee` is one entry from fetchAssignableEmployees(). + */ +export function assignAsset(instanceId, values, employee) { + return runAssetActivity(ASSET_ASSIGN_ACTIVITY_ID, ASSET_ASSIGN_FIELDS, instanceId, values, { + [ASSIGN_EMPLOYEE_FIELD]: employeeLookup(employee), + }) +} + +/** + * Run the Reassigned activity on an assigned asset instance. `employee` (the + * new holder) is one entry from fetchAssignableEmployees(). + */ +export function reassignAsset(instanceId, values, employee) { + return runAssetActivity(ASSET_REASSIGN_ACTIVITY_ID, ASSET_REASSIGN_FIELDS, instanceId, values, { + [REASSIGN_EMPLOYEE_FIELD]: employeeLookup(employee), + }) +} + +/** + * Run the RETURNED activity on an assigned asset instance. `employee` (who + * returned it) is one entry from fetchAssignableEmployees(). + */ +export function returnAsset(instanceId, values, employee) { + return runAssetActivity(ASSET_RETURN_ACTIVITY_ID, ASSET_RETURN_FIELDS, instanceId, values, { + [RETURN_EMPLOYEE_FIELD]: employeeLookup(employee), + }) +} + +/** + * Run the Lost activity on an asset instance. `employee` (the last owner) is + * one entry from fetchAssignableEmployees(), or null when there is none — + * then the lookup is left out. + */ +export function reportAssetLost(instanceId, values, employee) { + return runAssetActivity( + ASSET_LOST_ACTIVITY_ID, + ASSET_LOST_FIELDS, + instanceId, + values, + employee ? { [LOST_EMPLOYEE_FIELD]: employeeLookup(employee) } : {}, + ) +} + +/** Asset workflow states, as named in the builder diagram. */ +export const ASSET_STATE = { + AVAILABLE: 'Available', + ASSIGNED: 'Assigned', + CLOSED: 'Closed', +} + +/** True when a workflow row is in `state` (case-insensitive). */ +export const isAssetState = (row, state) => + instanceState(row).toLowerCase() === state.toLowerCase() + +/** + * An employee lookup value on a row ({ id, first_name }), or null. The runtime + * may send a lookup as an object or as its JSON text. + */ +function lookupValue(row, fieldId) { + let v = row?.[fieldId] + if (typeof v === 'string') { + try { + v = JSON.parse(v) + } catch { + return v.trim() ? { first_name: v.trim() } : null + } + } + if (v && typeof v === 'object') return v + return v != null && v !== '' ? { id: v } : null +} + +/** Display name from an employee lookup field, or ''. */ +export function lookupName(row, fieldId) { + const v = lookupValue(row, fieldId) + if (!v) return '' + return String(v.first_name ?? v.name ?? v.employee_code ?? (v.id != null ? `Employee ${v.id}` : '')) +} + +/** The employee-table id held by an employee lookup field, or null. */ +export function lookupId(row, fieldId) { + const id = lookupValue(row, fieldId)?.id + return id != null && id !== '' ? id : null +} + +/** + * The asset's latest hand-over: ASSIGNED or Reassigned, whichever is newer — + * { source, employeeField, name, id, date, by, remarks }. + * + * The instance keeps both activities' values, so after assign → reassign → + * return → assign the old reassignment is still on it. Compare their dates; + * the reassignment wins when it is dated the same or later, or when the + * assignment has no date. + */ +export function currentAssignment(row) { + const assign = { + source: 'assign', + employeeField: ASSIGN_EMPLOYEE_FIELD, + date: row?.field_1 ?? '', + by: row?.field_5 ?? '', + remarks: row?.field_6 ?? '', + } + const reassign = { + source: 'reassign', + employeeField: REASSIGN_EMPLOYEE_FIELD, + date: row?.field_4 ?? '', + by: row?.field_11 ?? '', + remarks: row?.field_12 ?? '', + } + let pick = assign + if (present(reassign.date) || lookupValue(row, REASSIGN_EMPLOYEE_FIELD)) { + const a = present(assign.date) ? toISODate(assign.date) : '' + const r = present(reassign.date) ? toISODate(reassign.date) : '' + if (!a || (r && r >= a)) pick = reassign + } + return { ...pick, name: lookupName(row, pick.employeeField), id: lookupId(row, pick.employeeField) } +} + +/** Who an asset is (or was last) assigned or reassigned to. */ +export const assignedEmployeeName = (row) => currentAssignment(row).name + +/** Activity values of one asset row, keyed by the derived columns below. */ +function activityValues(row) { + const current = currentAssignment(row) + return { + assigned_to: current.name, + assigned_date: current.date, + assigned_by: current.by, + assigned_department: row.field_21 ?? '', + returned_date: row.field_8 ?? '', + returned_by: lookupName(row, RETURN_EMPLOYEE_FIELD), + return_condition: row.field_29 ?? '', + state: instanceState(row), + } +} + +const derived = (field_key, output_label, data_type = 'text') => ({ + type: 'derived', + field_key, + output_label, + data_type, + sortable: false, +}) + +const STATE_COLUMN = derived('state', 'State', 'asset_state') + +/** Columns the asset record view doesn't have: the ASSIGNED values and the state. */ +const ASSIGNMENT_COLUMNS = [ + derived('assigned_to', 'Assigned to'), + derived('assigned_date', 'Assigned date', 'date'), + derived('assigned_by', 'Assigned by'), + derived('assigned_department', 'department'), + STATE_COLUMN, +] + +/** Columns for the return list: who had it, and the RETURNED values. */ +const RETURN_COLUMNS = [ + derived('assigned_to', 'Assigned to'), + derived('assigned_date', 'Assigned date', 'date'), + derived('returned_date', 'Returned date', 'date'), + derived('returned_by', 'Returned by'), + derived('return_condition', 'Condition'), + STATE_COLUMN, +] + +const inState = (state) => (row) => row.state.toLowerCase() === state.toLowerCase() +const always = () => true + +/** Assignment list filters: by workflow state. `match` picks the rows. */ +export const ASSIGNMENT_FILTERS = [ + { value: 'assigned', label: 'Assigned', match: inState(ASSET_STATE.ASSIGNED) }, + { value: 'available', label: 'Available', match: inState(ASSET_STATE.AVAILABLE) }, + { value: 'all', label: 'All assets', match: always }, +] + +/** Return list filters: waiting to come back, already back, or everything. */ +export const RETURN_FILTERS = [ + { value: 'assigned', label: 'To return (Assigned)', match: inState(ASSET_STATE.ASSIGNED) }, + { value: 'returned', label: 'Returned', match: (row) => row.returned_date !== '' }, + { value: 'all', label: 'All assets', match: always }, +] + +/** + * One page of an asset list with activity columns added. + * + * There is no separate assignment or return view: the asset record view is + * workflow-backed, so each row is an asset instance carrying its state and the + * latest ASSIGNED / RETURNED values. Load every row (with the same search and + * sort), add `columns`, keep the rows `filters` matches for `query.filter`, + * then page here. + */ +async function fetchAssetActivityPage(templateUid, query, { columns, filters }) { + const { page = 1, limit = 10, filter = filters[0].value, ...rest } = query + const { config, rows } = await fetchAllRecordRows(templateUid, rest) + const match = (filters.find((f) => f.value === filter) ?? filters[0]).match + + const data = rows.map((row) => ({ ...row, ...activityValues(row) })).filter(match) + + // Images make the rows tall and say nothing about who has the asset. + const fields = (config?.fields ?? []).filter((f) => f.data_type !== 'image') + const totalPages = Math.max(1, Math.ceil(data.length / limit)) + return { + config: { ...config, fields: [...fields, ...columns] }, + data: data.slice((page - 1) * limit, page * limit), + pagination: { page, limit, total_count: data.length, total_pages: totalPages }, + } +} + +/** One page of the asset assignment list. `query.filter` is one of ASSIGNMENT_FILTERS. */ +export const fetchAssignmentListPage = (templateUid, query = {}) => + fetchAssetActivityPage(templateUid, query, { columns: ASSIGNMENT_COLUMNS, filters: ASSIGNMENT_FILTERS }) + +/** One page of the asset return list. `query.filter` is one of RETURN_FILTERS. */ +export const fetchReturnListPage = (templateUid, query = {}) => + fetchAssetActivityPage(templateUid, query, { columns: RETURN_COLUMNS, filters: RETURN_FILTERS }) + +/** + * An INIT value of an asset row by builder label ('Category', 'Asset ID'…): + * from the asset view's column with that label, else from the raw field id. + */ +export function assetInitValue(config, row, label) { + const col = (config?.fields ?? []).find((f) => normLabel(f.output_label) === normLabel(label)) + const v = col ? row[col.field_key] : undefined + if (v != null && v !== '') return v + const def = ASSET_FIELDS.find((f) => normLabel(f.label) === normLabel(label)) + return def ? row[def.id] : undefined +} + +/** Every INIT field the form fills, plus Asset Owner, as { id, label, type, options?, slots? }. */ +const ASSET_FORM_FIELDS = [ + ...ASSET_FIELDS.filter((f) => !f.computed), + { id: ASSET_OWNER_FIELD, label: 'Asset Owner', type: 'lookup' }, +] + +/** The Add Asset field a view column shows, matched by field id or label. */ +export function assetFieldDef(key, label) { + return ASSET_FORM_FIELDS.find((d) => d.id === key || normLabel(d.label) === normLabel(label)) +} + +/** A select's option label for `value`, else `value` itself. */ +export const optionLabel = (def, value) => + def?.type === 'select' ? def.options?.find((o) => o.value === value)?.label ?? value : value + +/** Keys that hold the Asset Owner lookup: its field id, and any view column labelled so. */ +function ownerKeys(config) { + const keys = new Set([ASSET_OWNER_FIELD]) + for (const f of config?.fields ?? []) { + if (normLabel(f.output_label) === normLabel('Asset Owner')) keys.add(f.field_key) + } + return [...keys] +} + +/** + * Rows with Asset Owner replaced by the employee's name, for display. The views + * send the lookup as just { id }, so the name comes from the employee list; an + * owner who isn't in it (e.g. no longer active) shows as "Employee ". + */ +export async function withOwnerNames(config, rows) { + const keys = ownerKeys(config) + if (!rows.some((row) => keys.some((k) => lookupValue(row, k)))) return rows + + const employees = await fetchAssignableEmployees().catch((err) => { + if (err?.status === 401) throw err + return [] + }) + const nameById = new Map(employees.map((e) => [String(e.id), e.name])) + return rows.map((row) => { + const next = { ...row } + for (const k of keys) { + const v = lookupValue(row, k) + if (v) next[k] = (v.id != null && nameById.get(String(v.id))) || lookupName(row, k) + } + return next + }) +} + +/** Asset list rows for display: owner names, plus a column for every Add Asset field. */ +export async function withAssetListDisplay(res) { + const rows = Array.isArray(res?.data) ? res.data : [] + return withAssetFormColumns({ ...res, data: await withOwnerNames(res?.config, rows) }) +} + +/** What an INIT field shows: a select's option label, the owner's name. */ +function initDisplayValue(def, row) { + const v = row[def.id] + if (def.type === 'lookup') return lookupName(row, def.id) + return optionLabel(def, v) ?? '' +} + +/** + * Add a column for every INIT field the asset record view doesn't show yet + * (e.g. fields added in the builder after the view was set up), so everything + * the Add Asset form fills appears. A field counts as shown when the view has + * a column with its id or its label. The values come from the row's raw field + * ids, which the workflow-backed view sends along with its own columns. + */ +export function withAssetFormColumns(res) { + const fields = res?.config?.fields ?? [] + const shown = (def) => + fields.some((c) => c.field_key === def.id || normLabel(c.output_label) === normLabel(def.label)) + const missing = ASSET_FORM_FIELDS.filter((def) => !shown(def)) + if (!missing.length) return res + + const key = (def) => `init_${def.id}` + const columns = missing.map((def) => ({ + ...derived(key(def), def.label.trim(), ['select', 'lookup'].includes(def.type) ? 'text' : def.type), + slots: def.slots, + otherSlot: def.otherSlot, + })) + const rows = Array.isArray(res?.data) ? res.data : [] + return { + ...res, + config: { ...res?.config, fields: [...fields, ...columns] }, + data: rows.map((row) => ({ + ...row, + ...Object.fromEntries(missing.map((def) => [key(def), initDisplayValue(def, row)])), + })), + } +} + +/** The asset's generated code (e.g. ID-00002), or ''. */ +export function assetCode(config, row) { + const idGen = (config?.fields ?? []).find((f) => f.data_type === 'id_gen') + return String(assetInitValue(config, row, 'Asset ID') ?? (idGen ? row[idGen.field_key] : '') ?? '') +} + +const present = (v) => v !== undefined && v !== null && v !== '' +/** Non-empty parts joined with ' · '; `false` (from `cond && part`) is skipped. */ +const joinParts = (...parts) => parts.filter((p) => p !== false && present(p)).join(' · ') + +/** + * The history of one asset, oldest first: + * { id, action, date, employee, meta, notes }. + * + * The workflow keeps each activity's latest values on the instance, so this + * holds the purchase and the latest assignment, return and loss — an earlier + * assignment that was later replaced is not in the record any more. + */ +export function assetEvents(config, row) { + const val = (id) => row[id] + const events = [] + const add = (action, date, employee, meta, notes) => + events.push({ + id: `${row.instance_id}-${action}`, + action, + date: present(date) ? toISODate(date) : '', + employee: employee || '', + meta, + notes: notes || '', + }) + + const purchased = val('field_45') ?? row.created_at + if (present(purchased)) { + const cost = val('field_46') + add( + HISTORY_ACTION.PURCHASED, + purchased, + '', + joinParts( + assetInitValue(config, row, 'Category'), + assetInitValue(config, row, 'Model'), + present(cost) && `Cost ${cost}`, + ), + ) + } + + const assignee = lookupName(row, ASSIGN_EMPLOYEE_FIELD) + if (present(val('field_1')) || assignee) { + add( + HISTORY_ACTION.ASSIGNED, + val('field_1'), + assignee, + joinParts(present(val('field_5')) && `By ${val('field_5')}`, val('field_21')), + val('field_6'), + ) + } + + const reassignee = lookupName(row, REASSIGN_EMPLOYEE_FIELD) + if (present(val('field_4')) || reassignee) { + add( + HISTORY_ACTION.REASSIGNED, + val('field_4'), + reassignee, + joinParts(present(val('field_11')) && `By ${val('field_11')}`), + val('field_12'), + ) + } + + const returner = lookupName(row, RETURN_EMPLOYEE_FIELD) + if (present(val('field_8')) || returner) { + add( + HISTORY_ACTION.RETURNED, + val('field_8'), + returner, + joinParts(present(val('field_29')) && `Condition ${val('field_29')}`), + joinParts(val('field_9'), val('field_10')), + ) + } + + const lastOwner = lookupName(row, LOST_EMPLOYEE_FIELD) + if (present(val('field_36')) || lastOwner) { + add( + HISTORY_ACTION.LOST, + val('field_36'), + lastOwner, + joinParts( + val('field_37'), + present(val('field_39')) && `Ref ${val('field_39')}`, + present(val('field_40')) && `Recovery cost ${val('field_40')}`, + ), + val('field_38'), + ) + } + + // By date; undated events keep their workflow order. A re-assignment after + // a return is dated later than the return, so it sorts after it. + return events + .map((e, i) => ({ e, i })) + .sort((a, b) => (a.e.date && b.e.date ? a.e.date.localeCompare(b.e.date) : 0) || a.i - b.i) + .map(({ e }) => e) +} + +/** History list filters: by event kind. */ +export const HISTORY_FILTERS = [ + { value: 'all', label: 'All events' }, + { value: HISTORY_ACTION.PURCHASED, label: 'Purchased' }, + { value: HISTORY_ACTION.ASSIGNED, label: 'Assigned' }, + { value: HISTORY_ACTION.REASSIGNED, label: 'Reassigned' }, + { value: HISTORY_ACTION.RETURNED, label: 'Returned' }, + { value: HISTORY_ACTION.LOST, label: 'Lost' }, +] + +/** The history list's columns — every one derived from the asset rows. */ +const HISTORY_COLUMNS = [ + derived('date', 'Date', 'date'), + derived('asset_code', 'Asset ID', 'id_gen'), + derived('category', 'Category'), + derived('model', 'Model'), + derived('action', 'Event', 'history_action'), + derived('employee', 'Employee'), + derived('details', 'Details'), + STATE_COLUMN, +] + +/** + * One page of the asset history: every asset's events, newest first, one row + * per event. Search goes to the server (it matches asset fields); the event + * filter, order and paging happen here. `query.filter` is one of HISTORY_FILTERS. + */ +export async function fetchHistoryListPage(templateUid, query = {}) { + const { page = 1, limit = 10, filter = 'all', sortBy, sortDir, ...rest } = query + const { config, rows } = await fetchAllRecordRows(templateUid, rest) + + const data = rows + .flatMap((row) => { + const shared = { + instance_id: row.instance_id, + asset_code: assetCode(config, row), + category: assetInitValue(config, row, 'Category') ?? '', + model: assetInitValue(config, row, 'Model') ?? '', + state: instanceState(row), + } + return assetEvents(config, row).map((e) => ({ + ...shared, + event_id: e.id, + date: e.date, + action: e.action, + employee: e.employee, + details: joinParts(e.meta, e.notes), + })) + }) + .filter((e) => filter === 'all' || e.action === filter) + // Newest first; undated events last. + .sort((a, b) => (b.date || '').localeCompare(a.date || '')) + + const totalPages = Math.max(1, Math.ceil(data.length / limit)) + return { + config: { ...config, fields: HISTORY_COLUMNS }, + data: data.slice((page - 1) * limit, page * limit), + pagination: { page, limit, total_count: data.length, total_pages: totalPages }, + } +} + +/** + * One asset instance, or `row: null` when no instance has this id. + * + * The row comes from the asset record view (it carries the state and the + * activity values), with the asset detail view's values filled in where the + * record view has none — the detail view also holds fields the record view has + * no column for. `config` is the column config the details page shows: the + * detail view's fields when it sends any, else the record view's columns. + * If the detail view fails, the page still works from the record view alone. + * @returns {Promise<{config: object|null, row: object|null}>} + */ +export async function fetchAssetInstance(instanceId) { + const [{ config, rows }, detail] = await Promise.all([ + fetchAllRecordRows(RECORD_VIEWS.assets.templateUid), + fetchDetailView(DETAIL_VIEWS.assets.templateUid, instanceId).catch((err) => { + if (err?.status === 401) throw err + return null + }), + ]) + const listRow = rows.find((r) => String(r.instance_id) === String(instanceId)) || null + const view = detail ? normalizeDetailView(detail, { instanceId, fieldDefs: ASSET_FORM_FIELDS }) : null + const detailFields = (view?.sections ?? []).flatMap((s) => s.fields).filter((f) => f.key) + + if (!listRow && !detailFields.length) return { config, row: null } + + const detailValues = { + ...view?.record, + ...Object.fromEntries(detailFields.map((f) => [f.key, f.value])), + } + const row = { ...detailValues } + for (const [k, v] of Object.entries(listRow ?? {})) if (present(v) || !(k in row)) row[k] = v + + const detailConfig = detailFields.length && { + ...config, + fields: detailFields.map((f) => ({ field_key: f.key, output_label: f.label, data_type: f.type })), + } + const shownConfig = detailConfig || config + const [named] = await withOwnerNames(shownConfig, [row]) + return { config: shownConfig, row: named } +} diff --git a/src/api/auth.js b/src/api/auth.js new file mode 100644 index 0000000..621767e --- /dev/null +++ b/src/api/auth.js @@ -0,0 +1,33 @@ +import { apiFetch } from './client' + +/** + * POST /usr/login + * Request: { email, password } + * Response: { token: string, user: { id, org_id, name, email, timezone, roles: string[], groups: string[] } } + * Errors: 401 { error: "Invalid email or password" } + */ +export function login({ email, password }) { + return apiFetch('/usr/login', { + method: 'POST', + auth: false, + body: { email, password }, + }) +} + +/** Decode a JWT payload without verifying (only used to read `exp`). */ +export function decodeJwt(token) { + try { + const [, payload] = token.split('.') + const json = atob(payload.replace(/-/g, '+').replace(/_/g, '/')) + return JSON.parse(json) + } catch { + return null + } +} + +/** True when the token has an `exp` claim in the past. */ +export function isTokenExpired(token) { + const payload = decodeJwt(token) + if (!payload?.exp) return false + return payload.exp * 1000 <= Date.now() +} diff --git a/src/api/client.js b/src/api/client.js new file mode 100644 index 0000000..c4867e4 --- /dev/null +++ b/src/api/client.js @@ -0,0 +1,83 @@ +/** + * Thin fetch wrapper for the Zino backend. + * Base URL comes from VITE_API_BASE_URL (see .env.example); falls back to dev. + */ + +/** Auth host (login). */ +export const API_BASE_URL = ( + import.meta.env.VITE_API_BASE_URL || 'https://dev-apps.getzino.in' +).replace(/\/+$/, '') + +/** App/workflow host (activities such as "start"). */ +export const APP_API_BASE_URL = ( + import.meta.env.VITE_APP_API_BASE_URL || 'https://dev.getzino.in' +).replace(/\/+$/, '') + +export const TOKEN_KEY = 'oam.auth.token' +export const USER_KEY = 'oam.auth.user' + +export class ApiError extends Error { + constructor(message, { status, data } = {}) { + super(message) + this.name = 'ApiError' + this.status = status + this.data = data + } +} + +export function getStoredToken() { + try { + return localStorage.getItem(TOKEN_KEY) + } catch { + return null + } +} + +/** + * JSON request helper. Attaches the bearer token when one is stored, + * parses JSON responses and throws ApiError on non-2xx. + */ +export async function apiFetch(path, { method = 'GET', body, headers, auth = true, ...rest } = {}) { + const url = path.startsWith('http') ? path : `${API_BASE_URL}${path}` + const token = auth ? getStoredToken() : null + + let response + try { + response = await fetch(url, { + method, + headers: { + accept: 'application/json, text/plain, */*', + ...(body !== undefined && { 'content-type': 'application/json' }), + ...(token && { authorization: `Bearer ${token}` }), + ...headers, + }, + body: body !== undefined ? JSON.stringify(body) : undefined, + ...rest, + }) + } catch (err) { + throw new ApiError('Unable to reach the server. Check your connection and try again.', { + status: 0, + data: err, + }) + } + + const text = await response.text() + let data = null + if (text) { + try { + data = JSON.parse(text) + } catch { + data = text + } + } + + if (!response.ok) { + const message = + (data && typeof data === 'object' && (data.error || data.message)) || + (typeof data === 'string' && data) || + `Request failed (${response.status})` + throw new ApiError(message, { status: response.status, data }) + } + + return data +} diff --git a/src/api/dashboard.js b/src/api/dashboard.js new file mode 100644 index 0000000..95a763d --- /dev/null +++ b/src/api/dashboard.js @@ -0,0 +1,104 @@ +import { instanceState } from './activityForm' +import { + ASSET_FIELDS, + ASSET_STATE, + assetCode, + assetEvents, + assetInitValue, + currentAssignment, +} from './assets' +import { fetchEmployeeListPage } from './employees' +import { RECORD_VIEWS, fetchAllRecordRows } from './recordView' + +/** + * Everything the dashboard shows, from the same record views the other pages + * read (assets, employees), so the numbers always agree with them. + */ + +/** 'mobile_phone' -> 'Mobile phone', via the Category select's own labels. */ +const CATEGORY_LABELS = new Map( + (ASSET_FIELDS.find((f) => f.label === 'Category')?.options ?? []).map((o) => [o.value, o.label]), +) + +const toNumber = (v) => { + const n = Number(String(v ?? '').replace(/[^\d.-]/g, '')) + return Number.isFinite(n) ? n : 0 +} + +/** + * @returns {Promise<{ + * total: number, + * states: Array<{state: string, count: number}>, // Assigned, Available, Closed (+ any other) + * categories: Array<{label: string, count: number}>, // most first + * holders: Array<{id: any, name: string, count: number}>, // who has assets now, most first + * recent: Array, // newest events first (see assetEvents) + * inventoryValue: number, // sum of purchase costs + * employees: {active: number, inactive: number}, + * }>} + */ +export async function fetchDashboard() { + const [{ config, rows }, employeesRes] = await Promise.all([ + fetchAllRecordRows(RECORD_VIEWS.assets.templateUid), + fetchEmployeeListPage(RECORD_VIEWS.employees.templateUid, { filter: 'all', limit: 100000 }), + ]) + + // States in a fixed order, so each keeps its color; unknown ones follow. + const stateCounts = new Map([ + [ASSET_STATE.ASSIGNED, 0], + [ASSET_STATE.AVAILABLE, 0], + [ASSET_STATE.CLOSED, 0], + ]) + const categoryCounts = new Map() + const holders = new Map() + let inventoryValue = 0 + + for (const row of rows) { + const raw = instanceState(row) + const state = [...stateCounts.keys()].find((s) => s.toLowerCase() === raw.toLowerCase()) ?? (raw || 'Unknown') + stateCounts.set(state, (stateCounts.get(state) ?? 0) + 1) + + const cat = assetInitValue(config, row, 'Category') + const label = cat ? CATEGORY_LABELS.get(cat) ?? String(cat) : 'Uncategorised' + categoryCounts.set(label, (categoryCounts.get(label) ?? 0) + 1) + + inventoryValue += toNumber(assetInitValue(config, row, 'Purchase cost')) + + if (state === ASSET_STATE.ASSIGNED) { + // Assigned or reassigned, whichever is newer, is who has it now. + const { name, id } = currentAssignment(row) + if (name) { + const key = id ?? name + const h = holders.get(key) ?? { id: key, name, count: 0 } + h.count += 1 + holders.set(key, h) + } + } + } + + const recent = rows + .flatMap((row) => + assetEvents(config, row).map((e) => ({ + ...e, + instance_id: row.instance_id, + asset_code: assetCode(config, row), + })), + ) + .filter((e) => e.date) + .sort((a, b) => b.date.localeCompare(a.date)) + .slice(0, 6) + + const employeeRows = Array.isArray(employeesRes?.data) ? employeesRes.data : [] + const inactive = employeeRows.filter((r) => r.employment === 'Inactive').length + + return { + total: rows.length, + states: [...stateCounts].map(([state, count]) => ({ state, count })), + categories: [...categoryCounts] + .map(([label, count]) => ({ label, count })) + .sort((a, b) => b.count - a.count || a.label.localeCompare(b.label)), + holders: [...holders.values()].sort((a, b) => b.count - a.count || a.name.localeCompare(b.name)), + recent, + inventoryValue, + employees: { active: employeeRows.length - inactive, inactive }, + } +} diff --git a/src/api/detailView.js b/src/api/detailView.js new file mode 100644 index 0000000..9bb3943 --- /dev/null +++ b/src/api/detailView.js @@ -0,0 +1,181 @@ +import { APP_API_BASE_URL, apiFetch } from './client' +import { latestByLabel } from './activityForm' + +/** + * Detail view data source — one workflow record, looked up by instance id. + * + * GET {RUNTIME}/app/{APP_ID}/view/detailview/{DV_TEMPLATE_UID}?instance_id= + * Authorization: Bearer + * + * The response shape depends on how the template is built (a flat field list, + * sections, or just the record), so normalizeDetailView() turns any of them + * into one shape the page can render. + */ + +export const DETAIL_VIEW_APP_ID = '531' + +export const DETAIL_VIEWS = { + employees: { templateUid: 'd21498b4-55a3-4910-bbd4-57623b7b5c42' }, + // From the asset detail view's API sample (…/view/detailview/112771?instance_id=…). + assets: { templateUid: '112771' }, +} + +/** Fetch one record through a detail view template. */ +export function fetchDetailView(templateUid, instanceId) { + const qs = new URLSearchParams({ instance_id: String(instanceId) }) + return apiFetch( + `${APP_API_BASE_URL}/app/${DETAIL_VIEW_APP_ID}/view/detailview/${templateUid}?${qs}`, + ) +} + +const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v) +const isEmpty = (v) => v === undefined || v === null || v === '' + +/** 'Employee type', 'employee_type' and 'Employee-Type' all compare equal. */ +export const normLabel = (s) => + String(s ?? '').trim().toLowerCase().replace(/[\s_-]+/g, '_') + +/** Keys the runtime adds to every record — shown as record info, not as fields. */ +const META_KEY = + /^(id|_pk_value|instance_id|app_id|version|workflow_\w+|state|state_name|current_state|current_state_name|created_\w+|updated_\w+|modified_\w+)$/i + +/** Keys a record may nest its field values under. */ +const VALUE_CONTAINERS = ['data', 'form_data', 'fields', 'values', 'record_data'] + +/** 'created_at' -> 'Created at' */ +export function humanize(key) { + const s = String(key ?? '').replace(/[_-]+/g, ' ').trim() + return s ? s[0].toUpperCase() + s.slice(1) : '' +} + +/** An object that holds template config rather than record values. */ +const isConfigLike = (o) => 'config' in o || Array.isArray(o.fields) || Array.isArray(o.sections) + +/** Find the record object, wherever the response keeps it. */ +function findRecord(res, instanceId) { + // { data: { config, data } } keeps the record one level deeper; otherwise + // res.data.data is the record's own value container (lifted by flatten()). + const nested = isObj(res?.data) && isConfigLike(res.data) ? res.data.data : undefined + const candidates = [nested, res?.data?.record, res?.data, res?.record, res?.result] + for (const c of candidates) { + if (Array.isArray(c)) { + if (!c.length) continue + return c.find((r) => String(r?.instance_id) === String(instanceId)) || c[0] + } + if (isObj(c) && !isConfigLike(c)) return c + } + // Some templates return the bare record at the top level. + if (isObj(res) && !isConfigLike(res) && !('data' in res)) return res + return {} +} + +/** Lift nested value containers ({ data: { field_1 } }) up to one flat object. */ +function flatten(record) { + const flat = { ...record } + for (const key of VALUE_CONTAINERS) { + if (isObj(record[key])) { + Object.assign(flat, record[key]) + delete flat[key] + } + } + return flat +} + +/** Section and field config, wherever the response keeps it. */ +function findLayout(res) { + const cfg = res?.config ?? res?.data?.config ?? res?.template ?? res?.data?.template ?? {} + const sections = + cfg.sections ?? cfg.groups ?? cfg.tabs ?? cfg.layout?.sections ?? res?.sections ?? null + if (Array.isArray(sections) && sections.length) { + return sections.map((s) => ({ + title: s.title ?? s.label ?? s.name ?? s.section_name ?? '', + fields: s.fields ?? s.items ?? s.columns ?? [], + })) + } + const fields = cfg.fields ?? cfg.columns ?? res?.fields ?? null + if (Array.isArray(fields) && fields.length) return [{ title: '', fields }] + return null +} + +/** One configured field -> { key, label, type, value }. */ +function toField(f, record) { + const def = typeof f === 'string' ? { field_key: f } : f + const key = def.field_key ?? def.key ?? def.field_id ?? def.id ?? def.name + const label = String( + def.output_label ?? def.label ?? def.display_name ?? def.title ?? def.name ?? key ?? '', + ).trim() + const value = 'value' in def ? def.value : record[key] ?? record[label] + return { key, label: label || humanize(key), type: def.data_type ?? def.type ?? '', value } +} + +const isHidden = (f) => isObj(f) && (f.is_visible === false || f.hidden === true) + +/** + * Normalise any detail view response. + * + * `fieldDefs` ({ id, label, type }[]) names raw field ids when the template + * sends no field config. Several ids may carry one label (an INIT value and a + * later Update value); the last non-empty one in `fieldDefs` wins. + * + * @returns {{ sections: Array<{title: string, fields: Array}>, meta: Array, record: object, raw: any }} + */ +export function normalizeDetailView(res, { instanceId, fieldDefs = [] } = {}) { + const record = flatten(findRecord(res, instanceId)) + const layout = findLayout(res) + + const meta = Object.keys(record) + .filter((k) => META_KEY.test(k) && !isEmpty(record[k]) && !isObj(record[k])) + .map((k) => ({ key: k, label: humanize(k), value: record[k] })) + + if (layout) { + // A template may point at the INIT field (field_2) while a later Update + // wrote the new value to its own id (field_10) — show the newest one. + const latest = new Map( + [...latestByLabel(fieldDefs, record)].map(([label, v]) => [normLabel(label), v]), + ) + const labelOf = new Map(fieldDefs.map((d) => [d.id, d.label])) + const withLatest = (f) => { + const hit = [labelOf.get(f.key), f.key, f.label].map(normLabel).find((l) => latest.has(l)) + return hit ? { ...f, value: latest.get(hit) } : f + } + + const sections = layout + .map((s) => ({ + title: s.title, + fields: s.fields.filter((f) => !isHidden(f)).map((f) => withLatest(toField(f, record))), + })) + .filter((s) => s.fields.length) + return { sections, meta, record, raw: res } + } + + // No config: build the fields from the record itself. + const byLabel = new Map() + const used = new Set() + for (const def of fieldDefs) { + if (!(def.id in record)) continue + used.add(def.id) + const prev = byLabel.get(def.label) + const value = record[def.id] + if (!prev || !isEmpty(value)) { + byLabel.set(def.label, { key: def.id, label: def.label, type: def.type ?? '', value }) + } + } + const extra = Object.keys(record) + .filter((k) => !used.has(k) && !META_KEY.test(k)) + .map((k) => ({ key: k, label: humanize(k), type: '', value: record[k] })) + + const fields = [...byLabel.values(), ...extra] + return { sections: fields.length ? [{ title: '', fields }] : [], meta, record, raw: res } +} + +/** Value of the first field whose label or key matches (see normLabel). */ +export function detailValue(view, ...names) { + const wanted = names.map(normLabel) + for (const section of view?.sections ?? []) { + for (const f of section.fields) { + const hit = wanted.includes(normLabel(f.label)) || wanted.includes(normLabel(f.key)) + if (hit && !isEmpty(f.value)) return f.value + } + } + return undefined +} diff --git a/src/api/employees.js b/src/api/employees.js new file mode 100644 index 0000000..f76477a --- /dev/null +++ b/src/api/employees.js @@ -0,0 +1,405 @@ +import { APP_API_BASE_URL, ApiError, apiFetch } from './client' +import { buildActivityData, explainActivityError, isEndState, latestByLabel } from './activityForm' +import { normLabel } from './detailView' +import { RECORD_VIEWS, fetchAllRecordRows } from './recordView' + +/** + * Employee onboarding lives in a Zino workflow. Creating an employee means + * starting the workflow's INIT activity with the form data. + * + * POST {APP_API_BASE_URL}/app/{APP_ID}/start + * Authorization: Bearer + * { activity_id, workflow_uuid, version, data: { field_1 … field_12 } } + * + * NOTE ON IDS + * The uuid in the Zino editor URL is the workflow *version* uid, not the + * workflow uuid this endpoint wants. Both are listed by + * GET /api/apps/{appId}/workflows. + */ + +export const EMPLOYEE_APP_ID = '531' +export const EMPLOYEE_WORKFLOW_UUID = 'f4a303c2-869a-47d5-a5f3-1d2f7d5ae58e' +export const EMPLOYEE_VERSION_UID = 'cbd1b20f-d79f-40c2-91e9-7001de283328' +export const EMPLOYEE_INIT_ACTIVITY_ID = 'd42f026c-f9ac-4170-91c4-f1570d815d7a' +export const EMPLOYEE_WORKFLOW_VERSION = 1 + +/** + * INIT activity fields, exactly as configured in the Zino builder — same ids, + * labels, types, option values and order. + * Source: GET /api/apps/531/versions/{EMPLOYEE_VERSION_UID}/activities/{id}/data-fields + */ +export const EMPLOYEE_FIELDS = [ + { id: 'idgen_1', label: 'ID Generator', type: 'id_gen', computed: true }, + { id: 'field_1', label: 'first_name', type: 'text' }, + { id: 'field_2', label: 'email', type: 'email' }, + { id: 'field_3', label: 'phone', type: 'number' }, + { id: 'field_4', label: 'joining_date', type: 'date' }, + { + id: 'field_6', + label: 'Employee type', + type: 'select', + options: [{ label: 'permanent', value: 'permanent' }], + }, + { id: 'field_34', label: 'last_name', type: 'text' }, + { id: 'field_35', label: 'department', type: 'text' }, + { id: 'field_36', label: 'designation', type: 'text' }, + { id: 'field_37', label: 'status', type: 'text' }, + { id: 'field_38', label: 'employee_code', type: 'text' }, +] + +/** Start the employee INIT activity. Resolves with the backend response. */ +export async function createEmployee(values) { + try { + return await apiFetch(`${APP_API_BASE_URL}/app/${EMPLOYEE_APP_ID}/start`, { + method: 'POST', + body: { + activity_id: EMPLOYEE_INIT_ACTIVITY_ID, + workflow_uuid: EMPLOYEE_WORKFLOW_UUID, + version: EMPLOYEE_WORKFLOW_VERSION, + data: buildActivityData(EMPLOYEE_FIELDS, values), + }, + }) + } catch (err) { + throw explainActivityError(err) + } +} + +/** + * The "Update" activity in the Active state of the same employee workflow. + * Unlike INIT (which uses /start), a later activity runs against an existing + * record, so it posts to /activity and must carry the record's instance_id. + * + * POST {APP_API_BASE_URL}/app/{APP_ID}/activity + * Authorization: Bearer + * { activity_id, workflow_uuid, instance_id, data: { field_5, field_7 … field_15 } } + * + * NOTE ON IDS + * The Update activity has its own field ids (field_5, field_7–field_15), which + * differ from the INIT activity's ids above. Source: the Employee workflow + * "Update" activity, Fields tab, in the Zino builder. + */ +export const EMPLOYEE_UPDATE_ACTIVITY_ID = '95d39136-519f-4558-95e4-df326f6fb68c' + +export const EMPLOYEE_UPDATE_FIELDS = [ + { id: 'field_9', label: 'first_name', type: 'text' }, + { id: 'field_8', label: 'last_name', type: 'text' }, + { id: 'field_10', label: 'email', type: 'email' }, + { id: 'field_11', label: 'phone', type: 'number' }, + { id: 'field_5', label: 'joining_date', type: 'date' }, + { + id: 'field_7', + label: 'Employee type', + type: 'select', + options: [{ label: 'permanent', value: 'permanent' }], + }, + { id: 'field_12', label: 'department', type: 'text' }, + { id: 'field_13', label: 'designation', type: 'text' }, + { id: 'field_14', label: 'status', type: 'text' }, + { id: 'field_15', label: 'employee_code', type: 'text' }, +] + +/** + * The employee LIST the app shows is an rdbms record view: its rows carry only + * a table id (e.g. 2), NOT the workflow instance_id the Update activity needs. + * The workflow-backed employee view below does return instance_id, so we match + * a table row to its instance by a stable field (email, then phone). + * + * Source: the "Employees_details" screen's template (source_type: workflow). + */ +export const EMPLOYEE_WORKFLOW_VIEW_UID = '14d51468-d908-4cc3-a79c-e005b8fac5da' + +const normEmail = (v) => String(v ?? '').trim().toLowerCase() +const normPhone = (v) => String(v ?? '').replace(/\D/g, '') + +/** Every field id (INIT and Update) that carries a given label, e.g. 'email'. */ +const idsForLabel = (label) => + [...EMPLOYEE_FIELDS, ...EMPLOYEE_UPDATE_FIELDS].filter((f) => f.label === label).map((f) => f.id) +const EMAIL_IDS = idsForLabel('email') +const PHONE_IDS = idsForLabel('phone') + +/** First non-empty value a row holds for a label (by label key, then by field id). */ +const pick = (row, label, ids) => + [row[label], ...ids.map((id) => row[id])].find((v) => v !== undefined && v !== null && v !== '') + +/** Every employee workflow instance (field-id keyed rows). */ +const fetchEmployeeInstances = async () => + (await fetchAllRecordRows(EMPLOYEE_WORKFLOW_VIEW_UID)).rows + +/** + * The instance a list row belongs to, matched by email, then phone. An + * instance may hold the INIT value (field_2) and/or a later Update value + * (field_10), so compare against all of them. + */ +function matchInstance(row, instances) { + const email = normEmail(pick(row, 'email', EMAIL_IDS)) + const phone = normPhone(pick(row, 'phone', PHONE_IDS)) + const hasEmail = (r) => EMAIL_IDS.some((id) => normEmail(r[id]) === email) + const hasPhone = (r) => PHONE_IDS.some((id) => normPhone(r[id]) === phone) + return (email && instances.find(hasEmail)) || (phone && instances.find(hasPhone)) || null +} + +/** + * Resolve an employee list row to its workflow instance_id. + * Returns the id, or null when no matching instance is found. + */ +export async function resolveEmployeeInstanceId(row) { + if (!row) return null + // A workflow-backed row already carries the id — use it directly. + if (row.instance_id != null && row.instance_id !== '') return row.instance_id + + const match = matchInstance(row, await fetchEmployeeInstances()) + return match ? match.instance_id : null +} + +/** + * The employee-table row (asset_management.employees) an instance belongs to — + * the reverse of matchInstance(), by email then phone. Null if none matches. + */ +async function fetchEmployeeTableRow(instance) { + const emails = EMAIL_IDS.map((id) => normEmail(instance[id])).filter(Boolean) + const phones = PHONE_IDS.map((id) => normPhone(instance[id])).filter(Boolean) + const { rows } = await fetchAllRecordRows(RECORD_VIEWS.employees.templateUid) + return ( + rows.find((r) => emails.includes(normEmail(r.email))) || + rows.find((r) => phones.includes(normPhone(r.phone))) || + null + ) +} + +/** + * The "inactive" activity in the ACTIVE state of the same employee workflow. + * It moves the instance to End State, which has no activities, so it can run + * only once per employee. Like Update it posts to /activity with instance_id. + * + * POST {APP_API_BASE_URL}/app/{APP_ID}/activity + * Authorization: Bearer + * { activity_id, workflow_uuid, instance_id, + * data: { field_16, field_33, field_39: { id }, field_40: { id, employee_code } } } + * + * NOTE ON IDS + * Other activities in this version reuse similar names (Resign, Terminate…); + * only this uid is the "inactive" one. Source: + * GET /api/apps/531/versions/{EMPLOYEE_VERSION_UID}/activities/{id}/data-fields + */ +export const EMPLOYEE_INACTIVE_ACTIVITY_ID = '7dcde7c9-82ce-4dd5-bcca-68fca6904eb4' + +/** Fields the user fills in. */ +export const EMPLOYEE_INACTIVE_FIELDS = [ + { id: 'field_16', label: 'exit date', type: 'date' }, + { id: 'field_33', label: 'reason', type: 'longtext' }, +] + +/** + * Lookups into the asset_management.employees table, keyed by its bigint id. + * They identify the employee being deactivated, so they are filled from that + * employee's table row rather than the form. + */ +const INACTIVE_EMPLOYEE_ID_FIELD = 'field_39' // "Employee Id": { id } +const INACTIVE_EMPLOYEE_NAME_FIELD = 'field_40' // "Employee name": { id, employee_code } + +/** INIT, Update, then Inactive fields — oldest activity first, as latestByLabel() expects. */ +export const ALL_EMPLOYEE_FIELDS = [ + ...EMPLOYEE_FIELDS, + ...EMPLOYEE_UPDATE_FIELDS, + ...EMPLOYEE_INACTIVE_FIELDS, +] + +/** One employee workflow instance (field-id keyed), or null if not found. */ +export async function fetchEmployeeInstance(instanceId) { + const rows = await fetchEmployeeInstances() + return rows.find((r) => String(r.instance_id) === String(instanceId)) || null +} + +/** + * Show the latest saved values on the details page. + * + * The detail view template may read the INIT fields, or leave fields out, so + * after an Update it can still return old data. Merge in the workflow + * instance and give every field its newest value; fields the template leaves + * out but the instance holds are added at the end. If the instance can't be + * read, the view is returned as-is. + */ +export async function withLatestInstanceValues(view, instanceId) { + if (!view) return view + let instance + try { + instance = await fetchEmployeeInstance(instanceId) + } catch { + return view + } + if (!instance) return view + + const record = { ...view.record, ...instance } + const latest = new Map( + [...latestByLabel(ALL_EMPLOYEE_FIELDS, record)].map(([label, v]) => [normLabel(label), v]), + ) + const labelOf = new Map(ALL_EMPLOYEE_FIELDS.map((f) => [f.id, f.label])) + const shown = new Set() + + const sections = view.sections.map((s) => ({ + ...s, + fields: s.fields.map((f) => { + const hit = [labelOf.get(f.key), f.key, f.label].map(normLabel).find((l) => latest.has(l)) + if (!hit) return f + shown.add(hit) + return { ...f, value: latest.get(hit) } + }), + })) + + // One entry per label, typed from its newest field definition. + const missing = [] + for (const def of [...ALL_EMPLOYEE_FIELDS].reverse()) { + const n = normLabel(def.label) + if (shown.has(n) || !latest.has(n)) continue + shown.add(n) + missing.unshift({ key: def.id, label: def.label, type: def.type, value: latest.get(n) }) + } + if (missing.length) { + if (sections.length) sections[0] = { ...sections[0], fields: [...sections[0].fields, ...missing] } + else sections.push({ title: '', fields: missing }) + } + + return { ...view, sections, record, instance } +} + +/** + * Show the latest Update values in the employee list. + * + * The list is a table, and Zino copies an Update into it only for the fields + * mapped in the Update activity — unmapped ones keep their old value. The + * workflow instance always has what was saved, so each table row takes its + * instance's Update values (field_5, field_7 … field_15), its instance_id and + * its current_state_name. If the instances can't be read, the table is + * returned as-is. + */ +export async function withLatestEmployeeValues(res) { + const rows = Array.isArray(res?.data) ? res.data : [] + if (!rows.length) return res + + let instances + try { + instances = await fetchEmployeeInstances() + } catch { + return res + } + + const columns = res?.config?.fields ?? [] + const columnFor = (label) => + columns.find( + (c) => normLabel(c.field_key) === normLabel(label) || normLabel(c.output_label) === normLabel(label), + )?.field_key + + const data = rows.map((row) => { + const instance = matchInstance(row, instances) + if (!instance) return row + const next = { + ...row, + instance_id: row.instance_id ?? instance.instance_id, + current_state_name: instance.current_state_name, + } + for (const [label, value] of latestByLabel(EMPLOYEE_UPDATE_FIELDS, instance)) { + const key = columnFor(label) + if (key) next[key] = value + } + return next + }) + return { ...res, data } +} + +/** Employee list filter values: by workflow state (Inactive = End State). */ +export const EMPLOYMENT_FILTERS = [ + { value: 'active', label: 'Active' }, + { value: 'inactive', label: 'Inactive' }, + { value: 'all', label: 'All employees' }, +] + +/** Derived column showing each row's Active / Inactive badge. */ +const EMPLOYMENT_COLUMN = { + type: 'derived', + field_key: 'employment', + output_label: 'Employment', + data_type: 'employee_status', + sortable: false, +} + +/** + * One page of the employee list, filtered by workflow state. + * + * The table knows nothing about the workflow, so the server can't filter on + * it: load every row (with the same search and sort), attach each one's state, + * filter, then page here. `query.filter` is one of EMPLOYMENT_FILTERS; rows + * with no matching instance count as Active. + */ +export async function fetchEmployeeListPage(templateUid, query = {}) { + const { page = 1, limit = 10, filter = 'active', ...rest } = query + const { config, rows } = await fetchAllRecordRows(templateUid, rest) + const res = await withLatestEmployeeValues({ config, data: rows }) + + const data = res.data + .map((row) => ({ ...row, employment: isEndState(row) ? 'Inactive' : 'Active' })) + .filter((row) => filter === 'all' || row.employment.toLowerCase() === filter) + + const fields = config?.fields ?? [] + const totalPages = Math.max(1, Math.ceil(data.length / limit)) + return { + config: { ...config, fields: [...fields, EMPLOYMENT_COLUMN] }, + data: data.slice((page - 1) * limit, page * limit), + pagination: { page, limit, total_count: data.length, total_pages: totalPages }, + } +} + +/** + * Run a later activity (Update, Inactive, …) on an existing employee instance. + * `extra` is merged into `data` as-is, for values the form doesn't hold. + */ +async function runEmployeeActivity(activityId, fields, instanceId, values, extra = {}) { + try { + return await apiFetch(`${APP_API_BASE_URL}/app/${EMPLOYEE_APP_ID}/activity`, { + // The runtime only accepts POST here — PUT returns 405 Method Not Allowed. + method: 'POST', + body: { + activity_id: activityId, + data: { ...buildActivityData(fields, values), ...extra }, + // The runtime decodes this as int64, so "58950" (e.g. from a URL) is rejected. + instance_id: /^\d+$/.test(String(instanceId)) ? Number(instanceId) : instanceId, + workflow_uuid: EMPLOYEE_WORKFLOW_UUID, + }, + }) + } catch (err) { + throw explainActivityError(err) + } +} + +/** + * Run the Update activity on an existing employee instance (the one created by + * INIT). Resolves with the backend response. + */ +export function updateEmployee(instanceId, values) { + return runEmployeeActivity(EMPLOYEE_UPDATE_ACTIVITY_ID, EMPLOYEE_UPDATE_FIELDS, instanceId, values) +} + +/** + * Run the Inactive activity on an existing employee instance. The Employee Id + * and Employee name lookups are filled from the employee's table row. + */ +export async function inactivateEmployee(instanceId, values) { + const instance = await fetchEmployeeInstance(instanceId) + if (!instance) { + throw new ApiError('Could not find this employee in the workflow, so it cannot be marked inactive.') + } + const row = await fetchEmployeeTableRow(instance) + if (!row || row.id == null) { + throw new ApiError( + 'Could not find this employee in the employee table (matched by email, then phone), ' + + 'so Employee Id cannot be filled in.', + ) + } + const id = Number(row.id) + return runEmployeeActivity(EMPLOYEE_INACTIVE_ACTIVITY_ID, EMPLOYEE_INACTIVE_FIELDS, instanceId, values, { + [INACTIVE_EMPLOYEE_ID_FIELD]: { id }, + [INACTIVE_EMPLOYEE_NAME_FIELD]: { + id, + ...(row.employee_code != null && { employee_code: String(row.employee_code) }), + }, + }) +} diff --git a/src/api/recordView.js b/src/api/recordView.js new file mode 100644 index 0000000..ea92f5f --- /dev/null +++ b/src/api/recordView.js @@ -0,0 +1,97 @@ +import { APP_API_BASE_URL, apiFetch } from './client' + +/** + * Record view data source. + * + * POST {RUNTIME}/app/{APP_ID}/view/recordview + * Authorization: Bearer + * { + * rv_template_uid, preset_alias, params, + * search_query: { page, limit, sort_by, sort_dir, search, filters } + * } + * + * The response carries its own column config, so a view always renders exactly + * the fields the backend returns: + * { config: { fields: [...] }, data: [...], pagination: {...} } + */ + +export const RECORD_VIEW_APP_ID = '531' + +/** Record view templates, found via GET /api/apps/531/rv-screens. */ +export const RECORD_VIEWS = { + employees: { + templateUid: 'f3b07dfd-ddcf-4039-ba82-078179a16f21', + screen: 'emp', + rowKey: '_pk_value', + }, + assets: { + templateUid: 'c91be68b-2650-44ef-b566-8f7ca3b85b28', + screen: 'Asset_management', + rowKey: 'instance_id', + }, +} + +export const DEFAULT_PAGE_SIZE = 10 + +/** + * Fetch one page of a record view. + * @returns {Promise<{config: {fields: Array}, data: Array, pagination: object}>} + */ +export function fetchRecordView( + templateUid, + { page = 1, limit = DEFAULT_PAGE_SIZE, sortBy = '', sortDir = '', search = '', filters = [], params = {}, presetAlias = '' } = {}, +) { + return apiFetch(`${APP_API_BASE_URL}/app/${RECORD_VIEW_APP_ID}/view/recordview`, { + method: 'POST', + body: { + rv_template_uid: templateUid, + preset_alias: presetAlias, + params, + search_query: { + page, + limit, + sort_by: sortBy, + sort_dir: sortDir, + search, + filters, + }, + }, + }) +} + +const ALL_ROWS_PAGE_SIZE = 200 +const MAX_ALL_ROWS_PAGES = 10 + +/** + * Every row of a record view, paging through all. `query` (search, sort…) is + * passed on to each page request. + * @returns {Promise<{config: object|null, rows: Array}>} + */ +export async function fetchAllRecordRows(templateUid, query = {}) { + const rows = [] + let config = null + for (let page = 1; page <= MAX_ALL_ROWS_PAGES; page++) { + const res = await fetchRecordView(templateUid, { ...query, page, limit: ALL_ROWS_PAGE_SIZE }) + config ??= res?.config ?? null + const data = Array.isArray(res?.data) ? res.data : [] + rows.push(...data) + if (data.length < ALL_ROWS_PAGE_SIZE || page >= (res?.pagination?.total_pages ?? 1)) break + } + return { config, rows } +} + +/** Field types the backend reports that should render as a date. */ +const DATE_TYPES = new Set(['date', 'timestamp without time zone', 'timestamp with time zone']) + +/** Field types that should render right-aligned / monospaced. */ +const NUMERIC_TYPES = new Set(['number', 'bigint', 'integer', 'numeric', 'double precision']) + +export const isDateType = (t) => DATE_TYPES.has(t) +export const isNumericType = (t) => NUMERIC_TYPES.has(t) + +/** '2026-09-22T00:00:00Z' -> '2026-09-22' so the shared date formatter works. */ +export function toISODate(value) { + if (!value) return '' + const s = String(value) + return s.length >= 10 ? s.slice(0, 10) : s +} diff --git a/src/components/activity/ActivityFields.jsx b/src/components/activity/ActivityFields.jsx new file mode 100644 index 0000000..5ec2cdd --- /dev/null +++ b/src/components/activity/ActivityFields.jsx @@ -0,0 +1,260 @@ +import { Field, SelectInput, TextArea, TextInput } from '../ui' + +/** + * Renders one Zino activity field per builder definition, matching its + * data_type. Field order, labels and select options come straight from the + * builder, so the form always mirrors the activity. + * + * Computed fields (id_gen) are skipped — the builder's Fields tab does not + * list them and the workflow fills them in. + */ +export default function ActivityFields({ fields, values, errors = {}, onChange, disabled }) { + return fields.filter((f) => !f.computed).map((field) => { + const value = values[field.id] ?? '' + const set = (v) => onChange(field.id, v) + const isWide = field.type === 'longtext' || field.type === 'image' + + return ( + + {(id) => renderControl(field, id, value, set, disabled)} + + ) + }) +} + +function hintFor(field) { + switch (field.type) { + case 'geolocation': + return 'Latitude, longitude' + case 'image': + if (field.slots?.length) return `One photo per view, each up to ${field.maxSizeMb || 5} MB.` + return field.maxSizeMb ? `Image up to ${field.maxSizeMb} MB.` : 'Image' + case 'select': + return field.options?.length ? undefined : 'No options configured in the builder.' + default: + return undefined + } +} + +function renderControl(field, id, value, set, disabled) { + const common = { id, disabled } + + switch (field.type) { + case 'select': + return ( + set(e.target.value)} + options={field.options || []} + placeholder={field.options?.length ? `Select ${field.label.trim()}` : 'No options'} + disabled={disabled || !field.options?.length} + /> + ) + + case 'longtext': + return