Replace starter with office asset management app
This commit is contained in:
parent
d2d3a12e92
commit
b67b6783cd
20
.env.example
20
.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
|
||||
|
||||
10
.gitignore
vendored
10
.gitignore
vendored
@ -1,4 +1,8 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
.vite
|
||||
.DS_Store
|
||||
npm-debug.log*
|
||||
.env
|
||||
133
Employee_data.postman_collection.json
Normal file
133
Employee_data.postman_collection.json
Normal file
@ -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}"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
BIN
asset-lifecycle-diagram.png
Normal file
BIN
asset-lifecycle-diagram.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 93 KiB |
221
docs/asset-assignment-workflow.html
Normal file
221
docs/asset-assignment-workflow.html
Normal file
@ -0,0 +1,221 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Asset Assignment Workflow — states and activities</title>
|
||||
<style>
|
||||
/* Workflow image source. Render: screenshot headless at 1600x1120 (2x for the PNG). */
|
||||
html, body { margin: 0; background: #f3f5f8; overflow: hidden; }
|
||||
body { width: 1600px; height: 1410px; box-sizing: border-box; padding: 34px 44px 26px; font-family: 'Segoe UI', system-ui, -apple-system, sans-serif; color: #0f172a; font-size: 13px; }
|
||||
.mono { font-family: Consolas, 'Cascadia Mono', monospace; }
|
||||
|
||||
header { display: flex; justify-content: space-between; align-items: flex-end; margin-bottom: 14px; }
|
||||
h1 { margin: 0; font-size: 30px; font-weight: 700; letter-spacing: -0.01em; }
|
||||
.sub { margin: 5px 0 0; color: #5b6b80; font-size: 14px; }
|
||||
.legend { display: flex; gap: 8px; align-items: center; }
|
||||
.legend .lbl { font-size: 10.5px; letter-spacing: 0.1em; color: #8a99ad; margin-right: 6px; }
|
||||
.pill { display: inline-flex; align-items: center; gap: 7px; font-size: 12px; font-weight: 600; padding: 3px 11px; border-radius: 999px; white-space: nowrap; }
|
||||
.pill i { width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
|
||||
.p-unassigned { background: #dcf0e2; color: #15803d; }
|
||||
.p-assigned { background: #dde5fb; color: #1d4ed8; }
|
||||
.p-pending { background: #fbeed4; color: #b45309; }
|
||||
.p-deleted { background: #fadedd; color: #b91c1c; }
|
||||
|
||||
.section { font-size: 11px; letter-spacing: 0.1em; text-transform: uppercase; color: #8a99ad; font-weight: 600; margin: 0 0 6px; }
|
||||
svg { display: block; font-family: 'Segoe UI', system-ui, sans-serif; }
|
||||
|
||||
/* swimlane grid */
|
||||
.grid { display: grid; grid-template-columns: 150px repeat(6, 1fr); border-top: 2px solid #b7c2cf; margin-top: 16px; }
|
||||
.grid > div { border-bottom: 1px solid #d5dce5; border-right: 1px solid #e3e8ee; padding: 9px 10px; min-height: 0; }
|
||||
.grid > div:nth-child(7n) { border-right: 0; }
|
||||
.colhead { font-weight: 600; font-size: 13px; display: flex; align-items: center; gap: 8px; padding-block: 8px !important; }
|
||||
.colhead .n { width: 20px; height: 20px; border-radius: 50%; background: #111c2e; color: #fff; font-size: 11px; font-weight: 700; display: inline-flex; align-items: center; justify-content: center; }
|
||||
.rowhead { font-weight: 600; font-size: 13px; }
|
||||
.rowhead small { display: block; font-weight: 400; color: #8a99ad; font-size: 11px; margin-top: 2px; }
|
||||
.act { background: #fff; border: 1px solid #c9d2dd; border-radius: 6px; padding: 7px 9px; font-size: 12px; line-height: 1.35; }
|
||||
.act b { display: block; font-size: 12.5px; margin-bottom: 1px; }
|
||||
.act .d { color: #5b6b80; }
|
||||
.stack { display: grid; gap: 5px; }
|
||||
.act.assign { border-color: #1d4ed8; border-left-width: 3px; }
|
||||
.act.reassign { border-color: #15803d; border-left-width: 3px; }
|
||||
.act.delete { border-color: #b91c1c; border-left-width: 3px; }
|
||||
.act.sys { background: #eef2f6; border-color: #c9d2dd; }
|
||||
.tag { font-size: 10px; font-weight: 700; letter-spacing: 0.06em; margin-right: 4px; }
|
||||
.tag.assign { color: #1d4ed8; } .tag.reassign { color: #15803d; } .tag.delete { color: #b91c1c; }
|
||||
.none { color: #b7c2cf; font-size: 12px; font-style: italic; padding-top: 6px; }
|
||||
.state { display: inline-flex; align-items: center; gap: 6px; font-weight: 600; font-size: 12px; padding: 4px 10px; border-radius: 6px; border: 1.5px solid currentColor; white-space: nowrap; }
|
||||
.state .mono { font-weight: 400; font-size: 11px; opacity: .85; }
|
||||
.staterow { display: grid; gap: 5px; align-content: start; }
|
||||
.arrow { color: #8a99ad; font-size: 12px; }
|
||||
|
||||
footer { margin-top: 12px; display: flex; justify-content: space-between; font-size: 12.5px; color: #5b6b80; }
|
||||
footer b { color: #0f172a; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div>
|
||||
<h1>Asset Assignment Workflow</h1>
|
||||
<p class="sub">States an Assigned Asset can be in, and the activities the Asset Manager, the System and the Employee perform to move it between them.</p>
|
||||
</div>
|
||||
<div class="legend">
|
||||
<span class="lbl">ASSET STATE</span>
|
||||
<span class="pill p-unassigned"><i></i>Unassigned</span>
|
||||
<span class="pill p-assigned"><i></i>Assigned</span>
|
||||
<span class="pill p-pending"><i></i>Pending acknowledgement</span>
|
||||
<span class="pill p-deleted"><i></i>Deleted</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<p class="section">States · boxes are the four states, arrows are the activities that change state</p>
|
||||
<svg viewBox="0 0 1512 330" width="1512" height="330" role="img" aria-label="Asset state machine">
|
||||
<defs>
|
||||
<marker id="m" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse"><path d="M0 0 L10 5 L0 10 Z" fill="#475569"/></marker>
|
||||
</defs>
|
||||
|
||||
<!-- start -->
|
||||
<circle cx="48" cy="180" r="8" fill="#475569"/>
|
||||
<line x1="56" y1="180" x2="198" y2="180" stroke="#475569" stroke-width="2" marker-end="url(#m)"/>
|
||||
<text x="125" y="168" text-anchor="middle" font-size="12.5" font-weight="700" fill="#0f172a">Asset registered</text>
|
||||
<text x="125" y="200" text-anchor="middle" font-size="11.5" fill="#8a99ad" font-family="Consolas, monospace">asset tag issued</text>
|
||||
|
||||
<!-- Unassigned -->
|
||||
<rect x="200" y="142" width="200" height="76" rx="8" fill="#dcf0e2" stroke="#15803d" stroke-width="2"/>
|
||||
<text x="300" y="174" text-anchor="middle" font-size="17" font-weight="700" fill="#15803d" font-family="Consolas, monospace">Unassigned</text>
|
||||
<text x="300" y="198" text-anchor="middle" font-size="12" fill="#15803d" font-family="Consolas, monospace">in stock · no employee</text>
|
||||
|
||||
<!-- Assign -> Pending -->
|
||||
<line x1="400" y1="180" x2="558" y2="180" stroke="#475569" stroke-width="2" marker-end="url(#m)"/>
|
||||
<text x="480" y="166" text-anchor="middle" font-size="12.5" font-weight="700" fill="#0f172a">Assign</text>
|
||||
<text x="480" y="200" text-anchor="middle" font-size="11" fill="#8a99ad" font-family="Consolas, monospace">manager picks employee</text>
|
||||
|
||||
<!-- Pending -->
|
||||
<rect x="560" y="142" width="220" height="76" rx="8" fill="#fbeed4" stroke="#b45309" stroke-width="2"/>
|
||||
<text x="670" y="174" text-anchor="middle" font-size="17" font-weight="700" fill="#b45309" font-family="Consolas, monospace">Pending ack.</text>
|
||||
<text x="670" y="198" text-anchor="middle" font-size="12" fill="#b45309" font-family="Consolas, monospace">waiting for employee</text>
|
||||
|
||||
<!-- Pending -> Assigned -->
|
||||
<line x1="780" y1="180" x2="938" y2="180" stroke="#475569" stroke-width="2" marker-end="url(#m)"/>
|
||||
<text x="860" y="166" text-anchor="middle" font-size="12.5" font-weight="700" fill="#0f172a">Acknowledge</text>
|
||||
<text x="860" y="200" text-anchor="middle" font-size="11" fill="#8a99ad" font-family="Consolas, monospace">employee accepts</text>
|
||||
|
||||
<!-- Pending -> Unassigned (declined) : inner arc above -->
|
||||
<path d="M670 142 C670 90, 360 90, 360 140" fill="none" stroke="#475569" stroke-width="1.5" stroke-dasharray="5 4" marker-end="url(#m)"/>
|
||||
<text x="480" y="128" text-anchor="middle" font-size="11" fill="#5b6b80" font-family="Consolas, monospace">Declined / no reply in 3 days</text>
|
||||
|
||||
<!-- Assigned -->
|
||||
<rect x="940" y="134" width="270" height="92" rx="8" fill="#dde5fb" stroke="#1d4ed8" stroke-width="2"/>
|
||||
<text x="1075" y="164" text-anchor="middle" font-size="17" font-weight="700" fill="#1d4ed8" font-family="Consolas, monospace">Assigned</text>
|
||||
<text x="1075" y="186" text-anchor="middle" font-size="12" fill="#1d4ed8" font-family="Consolas, monospace">Employee Name · Employee ID</text>
|
||||
<text x="1075" y="206" text-anchor="middle" font-size="12" fill="#1d4ed8" font-family="Consolas, monospace">assigned on · by Asset Manager</text>
|
||||
|
||||
<!-- Reassign self-loop -->
|
||||
<path d="M1020 226 C1020 282, 1130 282, 1130 228" fill="none" stroke="#475569" stroke-width="2" marker-end="url(#m)"/>
|
||||
<text x="1075" y="300" text-anchor="middle" font-size="12.5" font-weight="700" fill="#0f172a">Reassign</text>
|
||||
<text x="1075" y="318" text-anchor="middle" font-size="11.5" fill="#8a99ad" font-family="Consolas, monospace">new Employee Name / ID · both notified · stays Assigned</text>
|
||||
|
||||
<!-- Assigned -> Unassigned (Delete: returned to stock) outer arc above -->
|
||||
<path d="M1075 134 C1075 40, 260 40, 260 140" fill="none" stroke="#475569" stroke-width="2" marker-end="url(#m)"/>
|
||||
<text x="668" y="40" text-anchor="middle" font-size="12.5" font-weight="700" fill="#0f172a">Delete · reason = Returned to stock</text>
|
||||
<text x="668" y="56" text-anchor="middle" font-size="11.5" fill="#8a99ad" font-family="Consolas, monospace">asset removed from employee, back in pool</text>
|
||||
|
||||
<!-- Assigned -> Deleted -->
|
||||
<line x1="1210" y1="180" x2="1370" y2="180" stroke="#475569" stroke-width="2" marker-end="url(#m)"/>
|
||||
<text x="1290" y="166" text-anchor="middle" font-size="12.5" font-weight="700" fill="#0f172a">Delete</text>
|
||||
<text x="1290" y="200" text-anchor="middle" font-size="11" fill="#8a99ad" font-family="Consolas, monospace">Lost · Damaged</text>
|
||||
<text x="1290" y="214" text-anchor="middle" font-size="11" fill="#8a99ad" font-family="Consolas, monospace">Retired</text>
|
||||
|
||||
<!-- Deleted -->
|
||||
<rect x="1372" y="142" width="132" height="76" rx="8" fill="#fadedd" stroke="#b91c1c" stroke-width="2"/>
|
||||
<rect x="1377" y="147" width="122" height="66" rx="5" fill="none" stroke="#b91c1c" stroke-width="1"/>
|
||||
<text x="1438" y="174" text-anchor="middle" font-size="17" font-weight="700" fill="#b91c1c" font-family="Consolas, monospace">Deleted</text>
|
||||
<text x="1438" y="198" text-anchor="middle" font-size="11.5" fill="#b91c1c" font-family="Consolas, monospace">soft · kept in log</text>
|
||||
</svg>
|
||||
|
||||
<p class="section" style="margin-top:14px">Activities · who does what, in order, and the asset state after each step</p>
|
||||
<div class="grid">
|
||||
<div class="colhead" style="color:#8a99ad;font-size:11px;letter-spacing:.08em">LANE</div>
|
||||
<div class="colhead"><span class="n">1</span>Find employee</div>
|
||||
<div class="colhead"><span class="n">2</span>View assigned assets</div>
|
||||
<div class="colhead"><span class="n">3</span>Choose activity</div>
|
||||
<div class="colhead"><span class="n">4</span>Fill details</div>
|
||||
<div class="colhead"><span class="n">5</span>Confirm & apply</div>
|
||||
<div class="colhead"><span class="n">6</span>Close out</div>
|
||||
|
||||
<!-- Asset Manager -->
|
||||
<div class="rowhead">Asset Manager<small>owns every decision</small></div>
|
||||
<div><div class="act"><b>Search employee</b><span class="d">by Employee Name or Employee ID</span></div></div>
|
||||
<div><div class="act"><b>Open profile</b><span class="d">reads Name · ID · Assigned Asset list</span></div></div>
|
||||
<div><div class="stack">
|
||||
<div class="act assign"><span class="tag assign">ASSIGN</span><b>Click “Assign asset”</b><span class="d">button on the profile header</span></div>
|
||||
<div class="act reassign"><span class="tag reassign">REASSIGN</span><b>Click “Reassign” on an asset row</b></div>
|
||||
<div class="act delete"><span class="tag delete">DELETE</span><b>Click “Delete” on an asset row</b></div>
|
||||
</div></div>
|
||||
<div><div class="stack">
|
||||
<div class="act assign"><b>Pick asset from pool</b><span class="d">only Unassigned assets offered · assigned-on date · condition</span></div>
|
||||
<div class="act reassign"><b>Pick new employee</b><span class="d">search Name / ID · must be active · transfer date</span></div>
|
||||
<div class="act delete"><b>Pick a reason</b><span class="d">Returned to stock · Lost · Damaged · Retired</span></div>
|
||||
</div></div>
|
||||
<div><div class="stack">
|
||||
<div class="act assign"><b>Confirm assignment</b><span class="d">summary: asset → employee</span></div>
|
||||
<div class="act reassign"><b>Confirm transfer</b><span class="d">summary: from → to · date</span></div>
|
||||
<div class="act delete"><b>Type asset tag to confirm</b><span class="d">guards against accidental delete</span></div>
|
||||
</div></div>
|
||||
<div><div class="act"><b>Sees refreshed profile</b><span class="d">updated Assigned Asset list + history entry</span></div></div>
|
||||
|
||||
<!-- System -->
|
||||
<div class="rowhead">System<small>validates · writes · notifies</small></div>
|
||||
<div><div class="act sys"><b>Match employee</b><span class="d">exact ID or name search</span></div></div>
|
||||
<div><div class="act sys"><b>Load assigned assets</b><span class="d">type · tag · assigned on · assigned by</span></div></div>
|
||||
<div><div class="act sys"><b>Show only valid actions</b><span class="d">Reassign / Delete only on assets in state Assigned</span></div></div>
|
||||
<div><div class="stack">
|
||||
<div class="act sys"><span class="tag assign">ASSIGN</span><b>Validate asset is Unassigned</b></div>
|
||||
<div class="act sys"><span class="tag reassign">REASSIGN</span><b>Validate new employee is active</b><span class="d">and not the current holder</span></div>
|
||||
<div class="act sys"><span class="tag delete">DELETE</span><b>Require reason</b></div>
|
||||
</div></div>
|
||||
<div><div class="stack">
|
||||
<div class="act sys"><span class="tag assign">ASSIGN</span><b>Set Pending ack. · notify employee</b></div>
|
||||
<div class="act sys"><span class="tag reassign">REASSIGN</span><b>Move asset to new employee</b><span class="d">never unassigned in between · notify both</span></div>
|
||||
<div class="act sys"><span class="tag delete">DELETE</span><b>Remove from employee</b><span class="d">Returned → Unassigned · else → Deleted</span></div>
|
||||
</div></div>
|
||||
<div><div class="act sys"><b>Write history entry</b><span class="d">who · what · when · reason · from → to. Append only, never edited.</span></div></div>
|
||||
|
||||
<!-- Employee -->
|
||||
<div class="rowhead">Employee<small>Name · ID on record</small></div>
|
||||
<div class="none">—</div>
|
||||
<div class="none">—</div>
|
||||
<div class="none">—</div>
|
||||
<div class="none">—</div>
|
||||
<div><div class="stack">
|
||||
<div class="act assign"><span class="tag assign">ASSIGN</span><b>Acknowledge receipt</b><span class="d">email / in-app · Pending → Assigned</span></div>
|
||||
<div class="act reassign"><span class="tag reassign">REASSIGN</span><b>Old holder hands over · new holder acknowledges</b></div>
|
||||
<div class="act delete"><span class="tag delete">DELETE</span><b>Gets notified</b><span class="d">nothing to do</span></div>
|
||||
</div></div>
|
||||
<div><div class="act"><b>Sees own asset list updated</b><span class="d">in employee view</span></div></div>
|
||||
|
||||
<!-- Asset state -->
|
||||
<div class="rowhead">Asset state<small>after the step</small></div>
|
||||
<div class="staterow"><span class="state" style="color:#1d4ed8;background:#dde5fb">Assigned <span class="mono">unchanged</span></span></div>
|
||||
<div class="staterow"><span class="state" style="color:#1d4ed8;background:#dde5fb">Assigned <span class="mono">unchanged</span></span></div>
|
||||
<div class="staterow"><span class="state" style="color:#1d4ed8;background:#dde5fb">Assigned <span class="mono">unchanged</span></span></div>
|
||||
<div class="staterow"><span class="state" style="color:#1d4ed8;background:#dde5fb">Assigned <span class="mono">unchanged</span></span></div>
|
||||
<div class="staterow">
|
||||
<span class="state" style="color:#b45309;background:#fbeed4"><span class="tag assign">ASSIGN</span>Pending ack.</span>
|
||||
<span class="state" style="color:#1d4ed8;background:#dde5fb"><span class="tag reassign">REASSIGN</span>Assigned <span class="mono">new holder</span></span>
|
||||
<span class="state" style="color:#15803d;background:#dcf0e2"><span class="tag delete">DELETE</span>Unassigned <span class="mono">returned</span></span>
|
||||
<span class="state" style="color:#b91c1c;background:#fadedd"><span class="tag delete">DELETE</span>Deleted <span class="mono">lost · damaged · retired</span></span>
|
||||
</div>
|
||||
<div class="staterow">
|
||||
<span class="state" style="color:#1d4ed8;background:#dde5fb"><span class="tag assign">ASSIGN</span>Assigned <span class="mono">after ack.</span></span>
|
||||
<span class="state" style="color:#1d4ed8;background:#dde5fb"><span class="tag reassign">REASSIGN</span>Assigned</span>
|
||||
<span class="state" style="color:#15803d;background:#dcf0e2"><span class="tag delete">DELETE</span>Unassigned</span>
|
||||
<span class="state" style="color:#b91c1c;background:#fadedd"><span class="tag delete">DELETE</span>Deleted <span class="mono">terminal</span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<span><b>Rule of the system:</b> an asset has exactly one state and at most one employee at a time; every state change writes exactly one history row.</span>
|
||||
<span>Rev 0.2 · 16 Sep 2026 · mock data</span>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
BIN
docs/asset-assignment-workflow.png
Normal file
BIN
docs/asset-assignment-workflow.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 468 KiB |
@ -3,12 +3,10 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<!--BASE_HREF-->
|
||||
<title>asset-management</title>
|
||||
<script src="./config.js"></script>
|
||||
<title>Office Asset Management</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
1740
package-lock.json
generated
Normal file
1740
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
15
package.json
15
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"
|
||||
}
|
||||
}
|
||||
36
src/App.jsx
Normal file
36
src/App.jsx
Normal file
@ -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 (
|
||||
<Routes>
|
||||
<Route path="login" element={<Login />} />
|
||||
|
||||
<Route element={<RequireAuth />}>
|
||||
<Route element={<AppLayout />}>
|
||||
<Route index element={<Dashboard />} />
|
||||
|
||||
<Route path="employees" element={<EmployeeList />} />
|
||||
<Route path="employees/:instanceId" element={<EmployeeDetails />} />
|
||||
|
||||
<Route path="assets" element={<AssetList />} />
|
||||
<Route path="assets/:assetId" element={<AssetDetails />} />
|
||||
{/* Keyed so moving between the lists starts fresh (filter, page). */}
|
||||
<Route path="assign" element={<AssetActivityList key="assign" kind="assign" />} />
|
||||
<Route path="return" element={<AssetActivityList key="return" kind="return" />} />
|
||||
<Route path="history" element={<AssetActivityList key="history" kind="history" />} />
|
||||
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Route>
|
||||
</Route>
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
119
src/api/activityForm.js
Normal file
119
src/api/activityForm.js
Normal file
@ -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<string, any>} 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
|
||||
}
|
||||
821
src/api/assets.js
Normal file
821
src/api/assets.js
Normal file
@ -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 <JWT>
|
||||
* { 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 <JWT>
|
||||
* { 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 <JWT>
|
||||
* { 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 <JWT>
|
||||
* { 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 <JWT>
|
||||
* { 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 <id>".
|
||||
*/
|
||||
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 }
|
||||
}
|
||||
33
src/api/auth.js
Normal file
33
src/api/auth.js
Normal file
@ -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()
|
||||
}
|
||||
83
src/api/client.js
Normal file
83
src/api/client.js
Normal file
@ -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
|
||||
}
|
||||
104
src/api/dashboard.js
Normal file
104
src/api/dashboard.js
Normal file
@ -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<object>, // 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 },
|
||||
}
|
||||
}
|
||||
181
src/api/detailView.js
Normal file
181
src/api/detailView.js
Normal file
@ -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=<id>
|
||||
* Authorization: Bearer <JWT>
|
||||
*
|
||||
* 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
|
||||
}
|
||||
405
src/api/employees.js
Normal file
405
src/api/employees.js
Normal file
@ -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 <JWT>
|
||||
* { 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 <JWT>
|
||||
* { 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 <JWT>
|
||||
* { 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) }),
|
||||
},
|
||||
})
|
||||
}
|
||||
97
src/api/recordView.js
Normal file
97
src/api/recordView.js
Normal file
@ -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 <JWT>
|
||||
* {
|
||||
* 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
|
||||
}
|
||||
260
src/components/activity/ActivityFields.jsx
Normal file
260
src/components/activity/ActivityFields.jsx
Normal file
@ -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 (
|
||||
<Field
|
||||
key={field.id}
|
||||
label={field.label.trim()}
|
||||
error={errors[field.id]}
|
||||
hint={hintFor(field)}
|
||||
full={isWide}
|
||||
>
|
||||
{(id) => renderControl(field, id, value, set, disabled)}
|
||||
</Field>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
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 (
|
||||
<SelectInput
|
||||
{...common}
|
||||
value={value}
|
||||
onChange={(e) => 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 <TextArea {...common} value={value} onChange={(e) => set(e.target.value)} />
|
||||
|
||||
case 'email':
|
||||
return (
|
||||
<TextInput
|
||||
{...common}
|
||||
type="email"
|
||||
value={value}
|
||||
onChange={(e) => set(e.target.value)}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'number':
|
||||
return (
|
||||
<TextInput
|
||||
{...common}
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
value={value}
|
||||
onChange={(e) => set(e.target.value)}
|
||||
/>
|
||||
)
|
||||
|
||||
case 'date':
|
||||
return (
|
||||
<TextInput {...common} type="date" value={value} onChange={(e) => set(e.target.value)} />
|
||||
)
|
||||
|
||||
case 'geolocation':
|
||||
return (
|
||||
<TextInput
|
||||
{...common}
|
||||
value={value}
|
||||
onChange={(e) => set(e.target.value)}
|
||||
placeholder="12.9716, 77.5946"
|
||||
/>
|
||||
)
|
||||
|
||||
case 'image':
|
||||
return field.slots?.length ? (
|
||||
<ImageSlots id={id} value={value} onChange={set} disabled={disabled} field={field} />
|
||||
) : (
|
||||
<ImageInput id={id} value={value} onChange={set} disabled={disabled} field={field} />
|
||||
)
|
||||
|
||||
default:
|
||||
return <TextInput {...common} value={value} onChange={(e) => set(e.target.value)} />
|
||||
}
|
||||
}
|
||||
|
||||
/** Reads `file` as a data URL, or rejects when it is over the builder's size cap. */
|
||||
function readImage(file, field) {
|
||||
const maxMb = field.maxSizeMb || 5
|
||||
if (file.size > maxMb * 1024 * 1024) {
|
||||
return Promise.reject(new Error(`Image must be ${maxMb} MB or smaller.`))
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(String(reader.result))
|
||||
reader.onerror = () => reject(reader.error)
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
}
|
||||
|
||||
/** File picker that enforces the builder's size cap and holds a data URL. */
|
||||
function ImageInput({ id, value, onChange, disabled, field }) {
|
||||
function handleFile(e) {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) {
|
||||
onChange('')
|
||||
return
|
||||
}
|
||||
readImage(file, field).then(onChange, (err) => {
|
||||
e.target.value = ''
|
||||
onChange('')
|
||||
window.alert(err.message)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="image-input">
|
||||
<input
|
||||
id={id}
|
||||
className="input"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleFile}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{value && <img className="image-input__preview" src={value} alt="" />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One picker per photo in `field.slots` (Front, Back, Side…), plus, when
|
||||
* `field.otherSlot` names one, a box that takes any number of extra photos.
|
||||
* The value is an object { Front: dataUrl, …, Other: [dataUrl, …] };
|
||||
* buildActivityData turns it into the list sent.
|
||||
*/
|
||||
function ImageSlots({ id, value, onChange, disabled, field }) {
|
||||
const images = value && typeof value === 'object' ? value : {}
|
||||
const other = field.otherSlot
|
||||
const others = (other && images[other]) || []
|
||||
|
||||
const setSlot = (slot, url) => {
|
||||
const next = { ...images }
|
||||
if (url && url.length) next[slot] = url
|
||||
else delete next[slot]
|
||||
onChange(Object.keys(next).length ? next : '')
|
||||
}
|
||||
|
||||
function addOthers(e) {
|
||||
const files = [...(e.target.files ?? [])]
|
||||
e.target.value = ''
|
||||
if (!files.length) return
|
||||
Promise.all(files.map((file) => readImage(file, field))).then(
|
||||
(urls) => setSlot(other, [...others, ...urls]),
|
||||
(err) => window.alert(err.message),
|
||||
)
|
||||
}
|
||||
|
||||
function handleFile(slot, e) {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) {
|
||||
setSlot(slot, '')
|
||||
return
|
||||
}
|
||||
readImage(file, field).then(
|
||||
(url) => setSlot(slot, url),
|
||||
(err) => {
|
||||
e.target.value = ''
|
||||
setSlot(slot, '')
|
||||
window.alert(err.message)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="image-slots">
|
||||
{field.slots.map((slot, i) => (
|
||||
<label key={slot} className="image-slot">
|
||||
<span className="image-slot__label">{slot}</span>
|
||||
{images[slot] ? (
|
||||
<img className="image-slot__preview" src={images[slot]} alt={`${slot} view`} />
|
||||
) : (
|
||||
<span className="image-slot__empty">No photo</span>
|
||||
)}
|
||||
<input
|
||||
id={i === 0 ? id : undefined}
|
||||
className="input"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={(e) => handleFile(slot, e)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
|
||||
{other && (
|
||||
<div className="image-slot image-slot--others">
|
||||
<span className="image-slot__label">
|
||||
{other}
|
||||
{others.length > 0 && ` (${others.length})`}
|
||||
</span>
|
||||
{others.length ? (
|
||||
<div className="image-slot__grid">
|
||||
{others.map((url, i) => (
|
||||
<div key={i} className="image-slot__thumb">
|
||||
<img src={url} alt={`${other} ${i + 1}`} />
|
||||
<button
|
||||
type="button"
|
||||
className="image-slot__remove"
|
||||
onClick={() => setSlot(other, others.filter((_, j) => j !== i))}
|
||||
disabled={disabled}
|
||||
aria-label={`Remove ${other} photo ${i + 1}`}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="image-slot__empty">No photos</span>
|
||||
)}
|
||||
<input
|
||||
className="input"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
onChange={addOthers}
|
||||
disabled={disabled}
|
||||
aria-label={`Add ${other} photos`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
75
src/components/activity/FieldValue.jsx
Normal file
75
src/components/activity/FieldValue.jsx
Normal file
@ -0,0 +1,75 @@
|
||||
import { isDateType, toISODate } from '../../api/recordView'
|
||||
import { formatDate } from '../../utils/format'
|
||||
|
||||
const ISO_DATE = /^\d{4}-\d{2}-\d{2}(T|\s|$)/
|
||||
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
const IMAGE_URL = /^https?:\/\/\S+\.(png|jpe?g|gif|webp|svg)(\?\S*)?$/i
|
||||
|
||||
const isImageSrc = (s) => typeof s === 'string' && (IMAGE_URL.test(s.trim()) || s.startsWith('data:image/'))
|
||||
|
||||
/**
|
||||
* The image sources in an image value: one URL, a list of them (photo slots),
|
||||
* or that list as JSON text.
|
||||
*/
|
||||
export function imageSources(value) {
|
||||
let v = value
|
||||
if (typeof v === 'string' && v.trim().startsWith('[')) {
|
||||
try {
|
||||
v = JSON.parse(v)
|
||||
} catch {
|
||||
/* not a list */
|
||||
}
|
||||
}
|
||||
const list = Array.isArray(v) ? v : [v]
|
||||
return list.map((s) => (typeof s === 'string' ? s.trim() : s?.url)).filter(Boolean)
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders one detail field ({ value, type, slots?, otherSlot? }) by its type,
|
||||
* falling back to what the value looks like. Shared by every details page.
|
||||
* `slots` names the photos of an image list (Front, Back…), in order; any
|
||||
* photos past them are named `otherSlot`.
|
||||
*/
|
||||
export default function FieldValue({ field }) {
|
||||
const { value, type, slots, otherSlot } = field
|
||||
const caption = (i) => (slots ? slots[i] ?? otherSlot : undefined)
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return <span className="cell-muted">—</span>
|
||||
}
|
||||
const s = typeof value === 'string' ? value.trim() : value
|
||||
|
||||
const images = type === 'image' || isImageSrc(s) || (Array.isArray(s) && s.every(isImageSrc))
|
||||
if (images) {
|
||||
return (
|
||||
<div className="record-thumbs">
|
||||
{imageSources(s).map((src, i) => (
|
||||
<figure key={i} className="record-thumbs__item">
|
||||
<img className="record-thumb" src={src} alt={caption(i) ?? ''} loading="lazy" />
|
||||
{caption(i) && <figcaption>{caption(i)}</figcaption>}
|
||||
</figure>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (isDateType(type) || (typeof s === 'string' && ISO_DATE.test(s))) {
|
||||
return formatDate(toISODate(s))
|
||||
}
|
||||
if (type === 'email' || (typeof s === 'string' && EMAIL.test(s))) {
|
||||
return <a className="link" href={`mailto:${s}`}>{s}</a>
|
||||
}
|
||||
if (typeof s === 'boolean') return s ? 'Yes' : 'No'
|
||||
if (type === 'geolocation' && typeof s === 'object') {
|
||||
const lat = s.lat ?? s.latitude
|
||||
const lng = s.lng ?? s.lon ?? s.longitude
|
||||
if (lat != null && lng != null) return `${lat}, ${lng}`
|
||||
}
|
||||
if (Array.isArray(s) && s.every((v) => typeof v !== 'object')) return s.join(', ')
|
||||
if (typeof s === 'object') return <span className="cell-muted">{JSON.stringify(s)}</span>
|
||||
return String(s)
|
||||
}
|
||||
|
||||
/** Timestamps read as dates; everything else as-is. */
|
||||
export function formatMeta(value) {
|
||||
const s = String(value)
|
||||
return ISO_DATE.test(s) ? `${formatDate(toISODate(s))}${s.length > 10 ? ` ${s.slice(11, 16)}` : ''}` : s
|
||||
}
|
||||
203
src/components/activity/RecordView.jsx
Normal file
203
src/components/activity/RecordView.jsx
Normal file
@ -0,0 +1,203 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
AssetStateBadge,
|
||||
Button,
|
||||
EmployeeStatusBadge,
|
||||
EmptyState,
|
||||
HistoryActionBadge,
|
||||
Icon,
|
||||
SearchInput,
|
||||
Toolbar,
|
||||
} from '../ui'
|
||||
import { isDateType, isNumericType, toISODate } from '../../api/recordView'
|
||||
import { formatDate } from '../../utils/format'
|
||||
import { imageSources } from './FieldValue'
|
||||
|
||||
/**
|
||||
* Renders a record view straight from the backend response: the columns are
|
||||
* whatever `config.fields` returns, in that order, with the backend's own
|
||||
* labels. Paging, sorting and search are handled by the server.
|
||||
* `toolbar` adds controls (e.g. a filter) next to the search box. A field with
|
||||
* `sortable: false` can't be sorted.
|
||||
*/
|
||||
export default function RecordView({
|
||||
fields,
|
||||
rows,
|
||||
pagination,
|
||||
loading,
|
||||
error,
|
||||
query,
|
||||
setPage,
|
||||
setSearch,
|
||||
setSort,
|
||||
rowKey = 'instance_id',
|
||||
onRowClick,
|
||||
emptyText = 'No records yet.',
|
||||
toolbar,
|
||||
}) {
|
||||
const [term, setTerm] = useState(query.search)
|
||||
|
||||
// Debounce typing so we don't fire a request per keystroke.
|
||||
useEffect(() => {
|
||||
if (term === query.search) return undefined
|
||||
const id = setTimeout(() => setSearch(term), 350)
|
||||
return () => clearTimeout(id)
|
||||
}, [term, query.search, setSearch])
|
||||
|
||||
const searchable = fields.filter((f) => f.is_search)
|
||||
const columns = fields.filter((f) => f.field_key !== 'id')
|
||||
|
||||
function toggleSort(field) {
|
||||
const dir = query.sortBy === field.field_key && query.sortDir === 'asc' ? 'desc' : 'asc'
|
||||
setSort(field.field_key, dir)
|
||||
}
|
||||
|
||||
const page = pagination?.page ?? 1
|
||||
const totalPages = pagination?.total_pages ?? 1
|
||||
const total = pagination?.total_count ?? rows.length
|
||||
|
||||
return (
|
||||
<>
|
||||
<Toolbar count={total} countLabel="records">
|
||||
{searchable.length > 0 && (
|
||||
<SearchInput
|
||||
value={term}
|
||||
onChange={setTerm}
|
||||
placeholder={`Search by ${searchable.map((f) => f.output_label).join(', ')}…`}
|
||||
/>
|
||||
)}
|
||||
{toolbar}
|
||||
{loading && <span className="toolbar__count">Loading…</span>}
|
||||
</Toolbar>
|
||||
|
||||
{error && (
|
||||
<div className="notice notice--danger" role="alert">
|
||||
<Icon name="alert" size={15} />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="table-wrap">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.map((field) => {
|
||||
const label = String(field.output_label || field.field_key).trim()
|
||||
if (field.sortable === false) return <th key={field.field_key}>{label}</th>
|
||||
const sorted = query.sortBy === field.field_key
|
||||
return (
|
||||
<th
|
||||
key={field.field_key}
|
||||
className={`is-sortable${sorted ? ' is-sorted' : ''}`}
|
||||
onClick={() => toggleSort(field)}
|
||||
>
|
||||
{label}
|
||||
<span className="table__sort">
|
||||
{sorted ? (query.sortDir === 'asc' ? '▲' : '▼') : '⇅'}
|
||||
</span>
|
||||
</th>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.length === 0 ? (
|
||||
<tr className="table__empty">
|
||||
<td colSpan={Math.max(columns.length, 1)}>
|
||||
<EmptyState
|
||||
icon={loading ? '⏳' : '📭'}
|
||||
title={loading ? 'Loading records' : 'No records'}
|
||||
text={loading ? 'Fetching from the workflow…' : emptyText}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
rows.map((row, i) => (
|
||||
<tr
|
||||
key={row[rowKey] ?? row._pk_value ?? row.id ?? i}
|
||||
className={onRowClick ? 'is-clickable' : undefined}
|
||||
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
||||
>
|
||||
{columns.map((field) => (
|
||||
<td key={field.field_key} className={cellClass(field)}>
|
||||
{renderCell(field, row[field.field_key])}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="pager">
|
||||
<Button
|
||||
size="sm"
|
||||
icon="chevronLeft"
|
||||
disabled={page <= 1 || loading}
|
||||
onClick={() => setPage(page - 1)}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<span className="pager__label">
|
||||
Page {page} of {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={page >= totalPages || loading}
|
||||
onClick={() => setPage(page + 1)}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function cellClass(field) {
|
||||
if (field.data_type === 'id_gen') return 'cell-mono'
|
||||
if (isNumericType(field.data_type)) return 'cell-mono'
|
||||
return undefined
|
||||
}
|
||||
|
||||
function renderCell(field, value) {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return <span className="cell-muted">—</span>
|
||||
}
|
||||
|
||||
if (field.data_type === 'image') {
|
||||
// Several photos (Front, Back…): show the first and how many more there are.
|
||||
const [first, ...more] = imageSources(value)
|
||||
if (!first) return <span className="cell-muted">—</span>
|
||||
return (
|
||||
<span className="record-thumbs">
|
||||
<img className="record-thumb" src={first} alt="" loading="lazy" />
|
||||
{more.length > 0 && <span className="cell-muted">+{more.length}</span>}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
if (isDateType(field.data_type)) {
|
||||
return formatDate(toISODate(value))
|
||||
}
|
||||
if (field.data_type === 'email') {
|
||||
return <span className="cell-muted">{String(value)}</span>
|
||||
}
|
||||
if (field.data_type === 'id_gen') {
|
||||
return <span className="cell-primary">{String(value)}</span>
|
||||
}
|
||||
if (field.data_type === 'employee_status') {
|
||||
return <EmployeeStatusBadge status={String(value)} />
|
||||
}
|
||||
if (field.data_type === 'asset_state') {
|
||||
return <AssetStateBadge state={String(value)} />
|
||||
}
|
||||
if (field.data_type === 'history_action') {
|
||||
return <HistoryActionBadge action={String(value)} />
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
return <span className="cell-muted">{JSON.stringify(value)}</span>
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
13
src/components/auth/RequireAuth.jsx
Normal file
13
src/components/auth/RequireAuth.jsx
Normal file
@ -0,0 +1,13 @@
|
||||
import { Navigate, Outlet, useLocation } from 'react-router-dom'
|
||||
import { useAuth } from '../../store/AuthContext'
|
||||
|
||||
/** Route wrapper: bounces anonymous visitors to /login, remembering where they were going. */
|
||||
export default function RequireAuth() {
|
||||
const { isAuthenticated } = useAuth()
|
||||
const location = useLocation()
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace state={{ from: location }} />
|
||||
}
|
||||
return <Outlet />
|
||||
}
|
||||
36
src/components/common/Timeline.jsx
Normal file
36
src/components/common/Timeline.jsx
Normal file
@ -0,0 +1,36 @@
|
||||
import { HISTORY_ACTION_TONE } from '../../constants'
|
||||
import { formatDate } from '../../utils/format'
|
||||
|
||||
/**
|
||||
* Vertical event timeline used by both asset and employee details.
|
||||
*
|
||||
* @param {Array<{
|
||||
* id: string,
|
||||
* action: string,
|
||||
* date: string,
|
||||
* title?: React.ReactNode, // defaults to the action name
|
||||
* meta?: React.ReactNode, // secondary line
|
||||
* notes?: string,
|
||||
* }>} events - already ordered by the caller
|
||||
*/
|
||||
export default function Timeline({ events }) {
|
||||
return (
|
||||
<ol className="timeline">
|
||||
{events.map((event) => (
|
||||
<li key={event.id} className="timeline__item">
|
||||
<span
|
||||
className={`timeline__dot timeline__dot--${
|
||||
HISTORY_ACTION_TONE[event.action] || 'neutral'
|
||||
}`}
|
||||
/>
|
||||
<div className="timeline__head">
|
||||
<span className="timeline__title">{event.title ?? event.action}</span>
|
||||
<span className="timeline__date">{formatDate(event.date)}</span>
|
||||
</div>
|
||||
{event.meta && <div className="timeline__meta">{event.meta}</div>}
|
||||
{event.notes && <div className="timeline__notes">{event.notes}</div>}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)
|
||||
}
|
||||
18
src/components/layout/AppLayout.jsx
Normal file
18
src/components/layout/AppLayout.jsx
Normal file
@ -0,0 +1,18 @@
|
||||
import { Outlet } from 'react-router-dom'
|
||||
import Sidebar from './Sidebar'
|
||||
import Header from './Header'
|
||||
|
||||
/** Shell: persistent sidebar + header, routed page in the content area. */
|
||||
export default function AppLayout() {
|
||||
return (
|
||||
<div className="app">
|
||||
<Sidebar />
|
||||
<div className="app__main">
|
||||
<Header />
|
||||
<main className="app__content">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
56
src/components/layout/Header.jsx
Normal file
56
src/components/layout/Header.jsx
Normal file
@ -0,0 +1,56 @@
|
||||
import { useLocation, useNavigate } from 'react-router-dom'
|
||||
import { Avatar, Button } from '../ui'
|
||||
import { NAV_ITEMS } from './Sidebar'
|
||||
import { useAuth } from '../../store/AuthContext'
|
||||
|
||||
function titleForPath(pathname) {
|
||||
const match = NAV_ITEMS.filter((i) => i.to !== '/')
|
||||
.find((i) => pathname.startsWith(i.to))
|
||||
if (match) return match.label
|
||||
return 'Dashboard'
|
||||
}
|
||||
|
||||
/** Secondary line under the user's name: first group, else roles, else email. */
|
||||
function subtitleFor(user) {
|
||||
if (!user) return ''
|
||||
if (user.groups?.length) return user.groups[0
|
||||
|
||||
]
|
||||
if (user.roles?.length) return user.roles.join(' · ')
|
||||
return user.email || ''
|
||||
}
|
||||
|
||||
export default function Header() {
|
||||
const { pathname } = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const { user, logout } = useAuth()
|
||||
const title = titleForPath(pathname)
|
||||
const displayName = user?.name || user?.email || 'Signed in'
|
||||
|
||||
function handleLogout() {
|
||||
logout()
|
||||
navigate('/login', { replace: true })
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="header">
|
||||
<div>
|
||||
<div className="header__title">{title}</div>
|
||||
<div className="header__crumb">Office Asset Management</div>
|
||||
</div>
|
||||
|
||||
<div className="header__right">
|
||||
<div className="header__user" title={user?.email}>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div className="header__user-name">{displayName}</div>
|
||||
<div className="header__user-role">{subtitleFor(user)}</div>
|
||||
</div>
|
||||
<Avatar name={displayName} />
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" icon="logout" onClick={handleLogout} title="Sign out">
|
||||
<span className="header__logout-label">Sign out</span>
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
54
src/components/layout/Sidebar.jsx
Normal file
54
src/components/layout/Sidebar.jsx
Normal file
@ -0,0 +1,54 @@
|
||||
import { NavLink } from 'react-router-dom'
|
||||
import Icon from '../ui/Icon'
|
||||
|
||||
export const NAV_ITEMS = [
|
||||
{ to: '/', label: 'Dashboard', icon: 'dashboard', end: true, section: 'Overview' },
|
||||
{ to: '/employees', label: 'Employees', icon: 'users', section: 'Manage' },
|
||||
{ to: '/assets', label: 'Assets', icon: 'box', section: 'Manage' },
|
||||
{ to: '/assign', label: 'Asset Assignment', icon: 'assign', section: 'Operations' },
|
||||
{ to: '/return', label: 'Asset Return', icon: 'return', section: 'Operations' },
|
||||
{ to: '/history', label: 'Asset History', icon: 'history', section: 'Operations' },
|
||||
]
|
||||
|
||||
export default function Sidebar() {
|
||||
let lastSection = null
|
||||
|
||||
return (
|
||||
<aside className="sidebar">
|
||||
<div className="sidebar__brand">
|
||||
<span className="sidebar__logo">OA</span>
|
||||
<div className="sidebar__brand-text">
|
||||
<div className="sidebar__title">Office Assets</div>
|
||||
<div className="sidebar__subtitle">Management</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="sidebar__nav">
|
||||
{NAV_ITEMS.map((item) => {
|
||||
const showSection = item.section !== lastSection
|
||||
lastSection = item.section
|
||||
return (
|
||||
<div key={item.to}>
|
||||
{showSection && <div className="sidebar__section">{item.section}</div>}
|
||||
<NavLink
|
||||
to={item.to}
|
||||
end={item.end}
|
||||
className={({ isActive }) =>
|
||||
`nav-item${isActive ? ' is-active' : ''}`
|
||||
}
|
||||
title={item.label}
|
||||
>
|
||||
<span className="nav-item__icon">
|
||||
<Icon name={item.icon} size={17} />
|
||||
</span>
|
||||
<span>{item.label}</span>
|
||||
</NavLink>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="sidebar__footer">v0.1.0 · Mock data</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
40
src/components/ui/Badge.jsx
Normal file
40
src/components/ui/Badge.jsx
Normal file
@ -0,0 +1,40 @@
|
||||
import {
|
||||
ASSET_STATE_TONE,
|
||||
ASSET_STATUS_TONE,
|
||||
EMPLOYEE_STATUS_TONE,
|
||||
HISTORY_ACTION_TONE,
|
||||
} from '../../constants'
|
||||
|
||||
/**
|
||||
* Generic pill.
|
||||
* @param {'success'|'warning'|'danger'|'info'|'neutral'} tone
|
||||
* @param {boolean} dot - show the leading dot (default true)
|
||||
*/
|
||||
export default function Badge({ children, tone = 'neutral', dot = true }) {
|
||||
return (
|
||||
<span className={`badge badge--${tone}${dot ? '' : ' badge--plain'}`}>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/** Badge for an asset status, coloured from the status map. */
|
||||
export function AssetStatusBadge({ status }) {
|
||||
return <Badge tone={ASSET_STATUS_TONE[status] || 'neutral'}>{status}</Badge>
|
||||
}
|
||||
|
||||
/** Badge for an asset's workflow state (Available, Assigned, Closed). */
|
||||
export function AssetStateBadge({ state }) {
|
||||
const tone = ASSET_STATE_TONE[String(state ?? '').trim().toLowerCase()] || 'neutral'
|
||||
return <Badge tone={tone}>{state}</Badge>
|
||||
}
|
||||
|
||||
/** Badge for an employee status. */
|
||||
export function EmployeeStatusBadge({ status }) {
|
||||
return <Badge tone={EMPLOYEE_STATUS_TONE[status] || 'neutral'}>{status}</Badge>
|
||||
}
|
||||
|
||||
/** Badge for a history timeline action. */
|
||||
export function HistoryActionBadge({ action }) {
|
||||
return <Badge tone={HISTORY_ACTION_TONE[action] || 'neutral'}>{action}</Badge>
|
||||
}
|
||||
34
src/components/ui/Button.jsx
Normal file
34
src/components/ui/Button.jsx
Normal file
@ -0,0 +1,34 @@
|
||||
import Icon from './Icon'
|
||||
|
||||
/**
|
||||
* @param {'primary'|'secondary'|'ghost'|'danger'} variant
|
||||
* @param {'sm'|'md'} size
|
||||
* @param {string} [icon] - name from the Icon set
|
||||
*/
|
||||
export default function Button({
|
||||
children,
|
||||
variant = 'secondary',
|
||||
size = 'md',
|
||||
icon,
|
||||
block = false,
|
||||
className = '',
|
||||
type = 'button',
|
||||
...rest
|
||||
}) {
|
||||
const classes = [
|
||||
'btn',
|
||||
`btn--${variant}`,
|
||||
size === 'sm' && 'btn--sm',
|
||||
block && 'btn--block',
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
|
||||
return (
|
||||
<button type={type} className={classes} {...rest}>
|
||||
{icon && <Icon name={icon} size={size === 'sm' ? 13 : 15} />}
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
26
src/components/ui/Card.jsx
Normal file
26
src/components/ui/Card.jsx
Normal file
@ -0,0 +1,26 @@
|
||||
/** Panel with an optional header row and right-aligned actions. */
|
||||
export default function Card({
|
||||
title,
|
||||
subtitle,
|
||||
actions,
|
||||
children,
|
||||
flush = false,
|
||||
className = '',
|
||||
}) {
|
||||
return (
|
||||
<section className={`card ${className}`}>
|
||||
{(title || actions) && (
|
||||
<header className="card__header">
|
||||
<div>
|
||||
{title && <h3 className="card__title">{title}</h3>}
|
||||
{subtitle && <p className="card__subtitle">{subtitle}</p>}
|
||||
</div>
|
||||
{actions && <div className="row">{actions}</div>}
|
||||
</header>
|
||||
)}
|
||||
<div className={`card__body${flush ? ' card__body--flush' : ''}`}>
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
33
src/components/ui/ConfirmDialog.jsx
Normal file
33
src/components/ui/ConfirmDialog.jsx
Normal file
@ -0,0 +1,33 @@
|
||||
import Modal from './Modal'
|
||||
import Button from './Button'
|
||||
|
||||
/**
|
||||
* Yes/no confirmation built on Modal.
|
||||
*
|
||||
* @param {'primary'|'danger'} tone - styling of the confirm button
|
||||
*/
|
||||
export default function ConfirmDialog({
|
||||
open,
|
||||
title = 'Are you sure?',
|
||||
message,
|
||||
confirmLabel = 'Confirm',
|
||||
cancelLabel = 'Cancel',
|
||||
tone = 'primary',
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}) {
|
||||
return (
|
||||
<Modal open={open} onClose={onCancel} title={title} size="sm"
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={onCancel}>{cancelLabel}</Button>
|
||||
<Button variant={tone} onClick={onConfirm}>
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="mt-0 muted">{message}</p>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
67
src/components/ui/Field.jsx
Normal file
67
src/components/ui/Field.jsx
Normal file
@ -0,0 +1,67 @@
|
||||
import { useId } from 'react'
|
||||
|
||||
/**
|
||||
* Label + control + hint/error wrapper. Wrap any input with it:
|
||||
* <Field label="Name" required error={errors.name}>
|
||||
* {(id) => <input id={id} className="input" ... />}
|
||||
* </Field>
|
||||
* `children` may be a render function (gets the generated id) or a node.
|
||||
*/
|
||||
export default function Field({
|
||||
label,
|
||||
required = false,
|
||||
hint,
|
||||
error,
|
||||
full = false,
|
||||
children,
|
||||
}) {
|
||||
const id = useId()
|
||||
const classes = ['field', full && 'field--full', error && 'field--invalid']
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
|
||||
return (
|
||||
<div className={classes}>
|
||||
{label && (
|
||||
<label className="field__label" htmlFor={id}>
|
||||
{label}
|
||||
{required && <span className="field__required">*</span>}
|
||||
</label>
|
||||
)}
|
||||
{typeof children === 'function' ? children(id) : children}
|
||||
{error ? (
|
||||
<span className="field__error">{error}</span>
|
||||
) : (
|
||||
hint && <span className="field__hint">{hint}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Text/date/number input bound to a Field. */
|
||||
export function TextInput({ id, ...rest }) {
|
||||
return <input id={id} className="input" {...rest} />
|
||||
}
|
||||
|
||||
/** Native select with an optional placeholder option. */
|
||||
export function SelectInput({ id, options = [], placeholder, ...rest }) {
|
||||
return (
|
||||
<select id={id} className="select" {...rest}>
|
||||
{placeholder && <option value="">{placeholder}</option>}
|
||||
{options.map((opt) => {
|
||||
const value = typeof opt === 'string' ? opt : opt.value
|
||||
const label = typeof opt === 'string' ? opt : opt.label
|
||||
return (
|
||||
<option key={value} value={value} disabled={opt.disabled}>
|
||||
{label}
|
||||
</option>
|
||||
)
|
||||
})}
|
||||
</select>
|
||||
)
|
||||
}
|
||||
|
||||
/** Multi-line input. */
|
||||
export function TextArea({ id, ...rest }) {
|
||||
return <textarea id={id} className="textarea" {...rest} />
|
||||
}
|
||||
47
src/components/ui/Icon.jsx
Normal file
47
src/components/ui/Icon.jsx
Normal file
@ -0,0 +1,47 @@
|
||||
/** Tiny inline-SVG icon set — avoids an icon dependency. */
|
||||
|
||||
const PATHS = {
|
||||
dashboard: 'M3 3h7v7H3zM14 3h7v4h-7zM14 10h7v11h-7zM3 13h7v8H3z',
|
||||
users: 'M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM22 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75',
|
||||
box: 'M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16zM3.27 6.96 12 12.01l8.73-5.05M12 22.08V12',
|
||||
assign: 'M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2M8.5 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM20 8v6M23 11h-6',
|
||||
return: 'M3 7v6h6M3.51 13a9 9 0 1 0 2.13-5.36L3 10',
|
||||
history: 'M12 8v4l3 3M3.05 11a9 9 0 1 1 .5 4M3 4v5h5',
|
||||
search: 'M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16zM21 21l-4.35-4.35',
|
||||
chevronLeft: 'M15 18l-6-6 6-6',
|
||||
chevronRight: 'M9 18l6-6-6-6',
|
||||
plus: 'M12 5v14M5 12h14',
|
||||
close: 'M18 6 6 18M6 6l12 12',
|
||||
check: 'M20 6 9 17l-5-5',
|
||||
alert: 'M12 9v4M12 17h.01M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z',
|
||||
mail: 'M4 4h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2zM22 6l-10 7L2 6',
|
||||
phone: 'M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6A19.79 19.79 0 0 1 2.12 4.18 2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.13.96.36 1.9.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.9.34 1.85.57 2.81.7A2 2 0 0 1 22 16.92z',
|
||||
pin: 'M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0zM12 13a3 3 0 1 0 0-6 3 3 0 0 0 0 6z',
|
||||
building: 'M3 21h18M5 21V7l7-4 7 4v14M9 9h.01M9 13h.01M9 17h.01M15 9h.01M15 13h.01M15 17h.01',
|
||||
tag: 'M20.59 13.41 13.42 20.6a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82zM7 7h.01',
|
||||
transfer: 'M16 3h5v5M21 3l-7 7M8 21H3v-5M3 21l7-7',
|
||||
laptop: 'M3 5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v11H3V5zM2 20h20',
|
||||
logout: 'M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9',
|
||||
}
|
||||
|
||||
export default function Icon({ name, size = 16, strokeWidth = 1.8, ...rest }) {
|
||||
const d = PATHS[name]
|
||||
if (!d) return null
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
{...rest}
|
||||
>
|
||||
<path d={d} />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
83
src/components/ui/Misc.jsx
Normal file
83
src/components/ui/Misc.jsx
Normal file
@ -0,0 +1,83 @@
|
||||
import { initials } from '../../utils/format'
|
||||
import Icon from './Icon'
|
||||
|
||||
/** Page title block with optional subtitle and right-hand actions. */
|
||||
export function PageHeader({ title, subtitle, actions }) {
|
||||
return (
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-header__title">{title}</h1>
|
||||
{subtitle && <p className="page-header__subtitle">{subtitle}</p>}
|
||||
</div>
|
||||
{actions && <div className="page-header__actions">{actions}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Placeholder shown when a list or panel has nothing to show. */
|
||||
export function EmptyState({ icon = '📭', title, text, action }) {
|
||||
return (
|
||||
<div className="empty">
|
||||
<div className="empty__icon">{icon}</div>
|
||||
{title && <div className="empty__title">{title}</div>}
|
||||
{text && <p className="empty__text">{text}</p>}
|
||||
{action && <div className="empty__action">{action}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Circular initials avatar. */
|
||||
export function Avatar({ name, size = 'md' }) {
|
||||
return (
|
||||
<span className={`avatar${size === 'lg' ? ' avatar--lg' : ''}`} aria-hidden="true">
|
||||
{initials(name)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
/** Label/value rows used across every details view. */
|
||||
export function InfoList({ children }) {
|
||||
return <div className="info-list">{children}</div>
|
||||
}
|
||||
|
||||
export function InfoRow({ label, children }) {
|
||||
return (
|
||||
<div className="info-row">
|
||||
<span className="info-row__label">{label}</span>
|
||||
<span className="info-row__value">{children ?? '—'}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** "← Back to …" control above a details view. */
|
||||
export function BackLink({ children, onClick }) {
|
||||
return (
|
||||
<button type="button" className="back-link" onClick={onClick}>
|
||||
<Icon name="chevronLeft" size={14} />
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Tab strip.
|
||||
* @param {Array<{key: string, label: string, count?: number}>} tabs
|
||||
*/
|
||||
export function Tabs({ tabs, active, onChange }) {
|
||||
return (
|
||||
<div className="tabs" role="tablist">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
role="tab"
|
||||
aria-selected={active === tab.key}
|
||||
className={`tab${active === tab.key ? ' is-active' : ''}`}
|
||||
onClick={() => onChange(tab.key)}
|
||||
>
|
||||
{tab.label}
|
||||
{tab.count != null && <span className="tab__count">{tab.count}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
67
src/components/ui/Modal.jsx
Normal file
67
src/components/ui/Modal.jsx
Normal file
@ -0,0 +1,67 @@
|
||||
import { useEffect } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import Icon from './Icon'
|
||||
|
||||
/**
|
||||
* Overlay dialog. Closes on Escape and on overlay click.
|
||||
*
|
||||
* @param {boolean} open
|
||||
* @param {() => void} onClose
|
||||
* @param {'sm'|'md'|'lg'} size
|
||||
* @param {React.ReactNode} [footer]
|
||||
*/
|
||||
export default function Modal({
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
subtitle,
|
||||
size = 'md',
|
||||
footer,
|
||||
children,
|
||||
}) {
|
||||
useEffect(() => {
|
||||
if (!open) return undefined
|
||||
const onKey = (e) => {
|
||||
if (e.key === 'Escape') onClose?.()
|
||||
}
|
||||
document.addEventListener('keydown', onKey)
|
||||
document.body.style.overflow = 'hidden'
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKey)
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
}, [open, onClose])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="modal__overlay"
|
||||
onMouseDown={(e) => {
|
||||
if (e.target === e.currentTarget) onClose?.()
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={`modal modal--${size}`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={typeof title === 'string' ? title : undefined}
|
||||
>
|
||||
<header className="modal__header">
|
||||
<div>
|
||||
<h2 className="modal__title">{title}</h2>
|
||||
{subtitle && <p className="modal__subtitle">{subtitle}</p>}
|
||||
</div>
|
||||
<button className="modal__close" onClick={onClose} aria-label="Close">
|
||||
<Icon name="close" size={17} />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="modal__body">{children}</div>
|
||||
|
||||
{footer && <footer className="modal__footer">{footer}</footer>}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
111
src/components/ui/Table.jsx
Normal file
111
src/components/ui/Table.jsx
Normal file
@ -0,0 +1,111 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
/**
|
||||
* Reusable data table.
|
||||
*
|
||||
* @param {Array<{
|
||||
* key: string,
|
||||
* header: string,
|
||||
* render?: (row) => React.ReactNode,
|
||||
* sortValue?: (row) => string|number,
|
||||
* sortable?: boolean,
|
||||
* className?: string,
|
||||
* width?: string,
|
||||
* }>} columns
|
||||
* @param {Array<object>} rows
|
||||
* @param {string} keyField - unique row field, defaults to 'id'
|
||||
* @param {(row) => void} [onRowClick]
|
||||
* @param {React.ReactNode} [empty] - rendered when there are no rows
|
||||
* @param {{key: string, dir: 'asc'|'desc'}} [defaultSort]
|
||||
*/
|
||||
export default function Table({
|
||||
columns,
|
||||
rows,
|
||||
keyField = 'id',
|
||||
onRowClick,
|
||||
empty = null,
|
||||
defaultSort = null,
|
||||
}) {
|
||||
const [sort, setSort] = useState(defaultSort)
|
||||
|
||||
const sortedRows = useMemo(() => {
|
||||
if (!sort) return rows
|
||||
const col = columns.find((c) => c.key === sort.key)
|
||||
if (!col) return rows
|
||||
const value = col.sortValue || ((row) => row[col.key])
|
||||
const dir = sort.dir === 'asc' ? 1 : -1
|
||||
|
||||
return rows.slice().sort((a, b) => {
|
||||
const va = value(a) ?? ''
|
||||
const vb = value(b) ?? ''
|
||||
if (typeof va === 'number' && typeof vb === 'number') return (va - vb) * dir
|
||||
return String(va).localeCompare(String(vb), undefined, { numeric: true }) * dir
|
||||
})
|
||||
}, [rows, sort, columns])
|
||||
|
||||
function toggleSort(col) {
|
||||
if (col.sortable === false) return
|
||||
setSort((prev) =>
|
||||
prev?.key === col.key
|
||||
? { key: col.key, dir: prev.dir === 'asc' ? 'desc' : 'asc' }
|
||||
: { key: col.key, dir: 'asc' },
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="table-wrap">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.map((col) => {
|
||||
const sortable = col.sortable !== false
|
||||
const isSorted = sort?.key === col.key
|
||||
return (
|
||||
<th
|
||||
key={col.key}
|
||||
style={col.width ? { width: col.width } : undefined}
|
||||
className={[
|
||||
col.className,
|
||||
sortable && 'is-sortable',
|
||||
isSorted && 'is-sorted',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
onClick={sortable ? () => toggleSort(col) : undefined}
|
||||
>
|
||||
{col.header}
|
||||
{sortable && (
|
||||
<span className="table__sort">
|
||||
{isSorted ? (sort.dir === 'asc' ? '▲' : '▼') : '⇅'}
|
||||
</span>
|
||||
)}
|
||||
</th>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedRows.length === 0 ? (
|
||||
<tr className="table__empty">
|
||||
<td colSpan={columns.length}>{empty}</td>
|
||||
</tr>
|
||||
) : (
|
||||
sortedRows.map((row) => (
|
||||
<tr
|
||||
key={row[keyField]}
|
||||
className={onRowClick ? 'is-clickable' : undefined}
|
||||
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
||||
>
|
||||
{columns.map((col) => (
|
||||
<td key={col.key} className={col.className}>
|
||||
{col.render ? col.render(row) : row[col.key]}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
58
src/components/ui/Toolbar.jsx
Normal file
58
src/components/ui/Toolbar.jsx
Normal file
@ -0,0 +1,58 @@
|
||||
import Icon from './Icon'
|
||||
|
||||
/** Row above a table holding search, filters and a result count. */
|
||||
export function Toolbar({ children, count, countLabel = 'results' }) {
|
||||
return (
|
||||
<div className="toolbar">
|
||||
{children}
|
||||
<div className="toolbar__spacer" />
|
||||
{count != null && (
|
||||
<span className="toolbar__count">
|
||||
{count} {countLabel}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Debounce-free search box; filtering these list sizes is instant. */
|
||||
export function SearchInput({ value, onChange, placeholder = 'Search…' }) {
|
||||
return (
|
||||
<div className="search">
|
||||
<span className="search__icon">
|
||||
<Icon name="search" size={15} />
|
||||
</span>
|
||||
<input
|
||||
className="input"
|
||||
type="search"
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
aria-label={placeholder}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Dropdown filter. `options` is a list of strings; an "all" entry is prepended.
|
||||
*/
|
||||
export function FilterSelect({ value, onChange, options, allLabel = 'All' }) {
|
||||
return (
|
||||
<div className="filter">
|
||||
<select
|
||||
className="select"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
aria-label={allLabel}
|
||||
>
|
||||
<option value="">{allLabel}</option>
|
||||
{options.map((opt) => (
|
||||
<option key={opt} value={opt}>
|
||||
{opt}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
25
src/components/ui/index.js
Normal file
25
src/components/ui/index.js
Normal file
@ -0,0 +1,25 @@
|
||||
/** Barrel export for the UI kit — import everything from '../components/ui'. */
|
||||
export { default as Icon } from './Icon'
|
||||
export { default as Button } from './Button'
|
||||
export {
|
||||
default as Badge,
|
||||
AssetStateBadge,
|
||||
AssetStatusBadge,
|
||||
EmployeeStatusBadge,
|
||||
HistoryActionBadge,
|
||||
} from './Badge'
|
||||
export { default as Card } from './Card'
|
||||
export { default as Table } from './Table'
|
||||
export { default as Modal } from './Modal'
|
||||
export { default as ConfirmDialog } from './ConfirmDialog'
|
||||
export { default as Field, TextInput, SelectInput, TextArea } from './Field'
|
||||
export { Toolbar, SearchInput, FilterSelect } from './Toolbar'
|
||||
export {
|
||||
PageHeader,
|
||||
EmptyState,
|
||||
Avatar,
|
||||
InfoList,
|
||||
InfoRow,
|
||||
BackLink,
|
||||
Tabs,
|
||||
} from './Misc'
|
||||
104
src/constants.js
Normal file
104
src/constants.js
Normal file
@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Central place for domain vocabulary.
|
||||
* Adding a new status / type / department only needs a change here.
|
||||
*/
|
||||
|
||||
export const ASSET_STATUS = {
|
||||
AVAILABLE: 'Available',
|
||||
ISSUED: 'Issued',
|
||||
MAINTENANCE: 'Maintenance',
|
||||
RETURNED: 'Returned',
|
||||
RETIRED: 'Retired',
|
||||
}
|
||||
|
||||
export const ASSET_STATUS_LIST = Object.values(ASSET_STATUS)
|
||||
|
||||
/** Maps a domain status to a Badge tone. */
|
||||
export const ASSET_STATUS_TONE = {
|
||||
[ASSET_STATUS.AVAILABLE]: 'success',
|
||||
[ASSET_STATUS.ISSUED]: 'info',
|
||||
[ASSET_STATUS.MAINTENANCE]: 'warning',
|
||||
[ASSET_STATUS.RETURNED]: 'neutral',
|
||||
[ASSET_STATUS.RETIRED]: 'danger',
|
||||
}
|
||||
|
||||
/** Badge tone per asset workflow state, keyed lower-case (states come from Zino). */
|
||||
export const ASSET_STATE_TONE = {
|
||||
available: 'success',
|
||||
assigned: 'info',
|
||||
closed: 'neutral',
|
||||
}
|
||||
|
||||
export const EMPLOYEE_STATUS = {
|
||||
ACTIVE: 'Active',
|
||||
ON_LEAVE: 'On Leave',
|
||||
INACTIVE: 'Inactive',
|
||||
}
|
||||
|
||||
export const EMPLOYEE_STATUS_LIST = Object.values(EMPLOYEE_STATUS)
|
||||
|
||||
export const EMPLOYEE_STATUS_TONE = {
|
||||
[EMPLOYEE_STATUS.ACTIVE]: 'success',
|
||||
[EMPLOYEE_STATUS.ON_LEAVE]: 'warning',
|
||||
[EMPLOYEE_STATUS.INACTIVE]: 'neutral',
|
||||
}
|
||||
|
||||
export const ASSET_TYPES = [
|
||||
'Laptop',
|
||||
'Desktop',
|
||||
'Monitor',
|
||||
'Mobile Phone',
|
||||
'Tablet',
|
||||
'Docking Station',
|
||||
'Headset',
|
||||
'Printer',
|
||||
'Keyboard & Mouse',
|
||||
]
|
||||
|
||||
export const DEPARTMENTS = [
|
||||
'Engineering',
|
||||
'Product',
|
||||
'Design',
|
||||
'Sales',
|
||||
'Marketing',
|
||||
'Human Resources',
|
||||
'Finance',
|
||||
'IT Support',
|
||||
]
|
||||
|
||||
export const LOCATIONS = [
|
||||
'Bengaluru',
|
||||
'Pune',
|
||||
'Mumbai',
|
||||
'Hyderabad',
|
||||
'Remote',
|
||||
]
|
||||
|
||||
export const ASSET_CONDITIONS = ['New', 'Excellent', 'Good', 'Fair', 'Damaged']
|
||||
|
||||
/** Timeline event kinds recorded in asset history. */
|
||||
export const HISTORY_ACTION = {
|
||||
PURCHASED: 'Purchased',
|
||||
AVAILABLE: 'Available',
|
||||
ISSUED: 'Issued',
|
||||
ASSIGNED: 'Assigned',
|
||||
REASSIGNED: 'Reassigned',
|
||||
RETURNED: 'Returned',
|
||||
TRANSFERRED: 'Transferred',
|
||||
MAINTENANCE: 'Maintenance',
|
||||
LOST: 'Lost',
|
||||
RETIRED: 'Retired',
|
||||
}
|
||||
|
||||
export const HISTORY_ACTION_TONE = {
|
||||
[HISTORY_ACTION.PURCHASED]: 'neutral',
|
||||
[HISTORY_ACTION.AVAILABLE]: 'success',
|
||||
[HISTORY_ACTION.ISSUED]: 'info',
|
||||
[HISTORY_ACTION.ASSIGNED]: 'info',
|
||||
[HISTORY_ACTION.REASSIGNED]: 'warning',
|
||||
[HISTORY_ACTION.LOST]: 'danger',
|
||||
[HISTORY_ACTION.RETURNED]: 'neutral',
|
||||
[HISTORY_ACTION.TRANSFERRED]: 'warning',
|
||||
[HISTORY_ACTION.MAINTENANCE]: 'warning',
|
||||
[HISTORY_ACTION.RETIRED]: 'danger',
|
||||
}
|
||||
409
src/data/mockData.js
Normal file
409
src/data/mockData.js
Normal file
@ -0,0 +1,409 @@
|
||||
import { ASSET_STATUS, EMPLOYEE_STATUS } from '../constants'
|
||||
|
||||
/**
|
||||
* Mock seed data. Dates are stored as ISO (YYYY-MM-DD) and formatted for
|
||||
* display, so sorting and comparisons stay simple.
|
||||
*/
|
||||
|
||||
export const employees = [
|
||||
{
|
||||
id: 'EMP-001',
|
||||
name: 'Rahul Kumar',
|
||||
email: 'rahul.kumar@company.com',
|
||||
phone: '+91 98450 11234',
|
||||
department: 'Engineering',
|
||||
designation: 'Senior Software Engineer',
|
||||
location: 'Bengaluru',
|
||||
status: EMPLOYEE_STATUS.ACTIVE,
|
||||
joinDate: '2022-04-11',
|
||||
manager: 'Anita Desai',
|
||||
},
|
||||
{
|
||||
id: 'EMP-002',
|
||||
name: 'Priya Sharma',
|
||||
email: 'priya.sharma@company.com',
|
||||
phone: '+91 98200 44521',
|
||||
department: 'Product',
|
||||
designation: 'Product Manager',
|
||||
location: 'Mumbai',
|
||||
status: EMPLOYEE_STATUS.ACTIVE,
|
||||
joinDate: '2021-08-02',
|
||||
manager: 'Anita Desai',
|
||||
},
|
||||
{
|
||||
id: 'EMP-003',
|
||||
name: 'Arjun Mehta',
|
||||
email: 'arjun.mehta@company.com',
|
||||
phone: '+91 99870 22310',
|
||||
department: 'Design',
|
||||
designation: 'UX Designer',
|
||||
location: 'Pune',
|
||||
status: EMPLOYEE_STATUS.ACTIVE,
|
||||
joinDate: '2023-01-16',
|
||||
manager: 'Priya Sharma',
|
||||
},
|
||||
{
|
||||
id: 'EMP-004',
|
||||
name: 'Sneha Iyer',
|
||||
email: 'sneha.iyer@company.com',
|
||||
phone: '+91 97400 77812',
|
||||
department: 'Human Resources',
|
||||
designation: 'HR Business Partner',
|
||||
location: 'Bengaluru',
|
||||
status: EMPLOYEE_STATUS.ON_LEAVE,
|
||||
joinDate: '2020-11-23',
|
||||
manager: 'Vikram Rao',
|
||||
},
|
||||
{
|
||||
id: 'EMP-005',
|
||||
name: 'Vikram Rao',
|
||||
email: 'vikram.rao@company.com',
|
||||
phone: '+91 90080 55190',
|
||||
department: 'IT Support',
|
||||
designation: 'IT Manager',
|
||||
location: 'Hyderabad',
|
||||
status: EMPLOYEE_STATUS.ACTIVE,
|
||||
joinDate: '2019-06-03',
|
||||
manager: '—',
|
||||
},
|
||||
{
|
||||
id: 'EMP-006',
|
||||
name: 'Neha Gupta',
|
||||
email: 'neha.gupta@company.com',
|
||||
phone: '+91 98110 30045',
|
||||
department: 'Finance',
|
||||
designation: 'Financial Analyst',
|
||||
location: 'Mumbai',
|
||||
status: EMPLOYEE_STATUS.ACTIVE,
|
||||
joinDate: '2022-09-19',
|
||||
manager: 'Vikram Rao',
|
||||
},
|
||||
{
|
||||
id: 'EMP-007',
|
||||
name: 'Karthik Nair',
|
||||
email: 'karthik.nair@company.com',
|
||||
phone: '+91 95910 66723',
|
||||
department: 'Sales',
|
||||
designation: 'Account Executive',
|
||||
location: 'Remote',
|
||||
status: EMPLOYEE_STATUS.ACTIVE,
|
||||
joinDate: '2023-07-10',
|
||||
manager: 'Priya Sharma',
|
||||
},
|
||||
{
|
||||
id: 'EMP-008',
|
||||
name: 'Ananya Reddy',
|
||||
email: 'ananya.reddy@company.com',
|
||||
phone: '+91 91600 28834',
|
||||
department: 'Marketing',
|
||||
designation: 'Marketing Specialist',
|
||||
location: 'Hyderabad',
|
||||
status: EMPLOYEE_STATUS.INACTIVE,
|
||||
joinDate: '2021-02-08',
|
||||
manager: 'Priya Sharma',
|
||||
},
|
||||
{
|
||||
id: 'EMP-009',
|
||||
name: 'Mohit Verma',
|
||||
email: 'mohit.verma@company.com',
|
||||
phone: '+91 99300 41276',
|
||||
department: 'Engineering',
|
||||
designation: 'DevOps Engineer',
|
||||
location: 'Pune',
|
||||
status: EMPLOYEE_STATUS.ACTIVE,
|
||||
joinDate: '2023-03-27',
|
||||
manager: 'Rahul Kumar',
|
||||
},
|
||||
{
|
||||
id: 'EMP-010',
|
||||
name: 'Divya Menon',
|
||||
email: 'divya.menon@company.com',
|
||||
phone: '+91 94480 90012',
|
||||
department: 'Engineering',
|
||||
designation: 'QA Engineer',
|
||||
location: 'Bengaluru',
|
||||
status: EMPLOYEE_STATUS.ACTIVE,
|
||||
joinDate: '2024-01-08',
|
||||
manager: 'Rahul Kumar',
|
||||
},
|
||||
]
|
||||
|
||||
export const assets = [
|
||||
{
|
||||
id: 'AST-001',
|
||||
assetTag: 'LAP-001',
|
||||
type: 'Laptop',
|
||||
brand: 'Dell',
|
||||
model: 'Latitude 5440',
|
||||
serialNumber: 'XYZ123',
|
||||
status: ASSET_STATUS.ISSUED,
|
||||
assignedTo: 'EMP-001',
|
||||
assignedDate: '2026-09-15',
|
||||
condition: 'Excellent',
|
||||
purchaseDate: '2024-02-12',
|
||||
warrantyExpiry: '2027-02-12',
|
||||
cost: 92000,
|
||||
location: 'Bengaluru',
|
||||
vendor: 'Dell India',
|
||||
},
|
||||
{
|
||||
id: 'AST-002',
|
||||
assetTag: 'LAP-002',
|
||||
type: 'Laptop',
|
||||
brand: 'Apple',
|
||||
model: 'MacBook Pro 14 M3',
|
||||
serialNumber: 'C02XK9PQ1',
|
||||
status: ASSET_STATUS.ISSUED,
|
||||
assignedTo: 'EMP-002',
|
||||
assignedDate: '2025-06-01',
|
||||
condition: 'Excellent',
|
||||
purchaseDate: '2025-05-20',
|
||||
warrantyExpiry: '2028-05-20',
|
||||
cost: 185000,
|
||||
location: 'Mumbai',
|
||||
vendor: 'Apple Authorised Reseller',
|
||||
},
|
||||
{
|
||||
id: 'AST-003',
|
||||
assetTag: 'LAP-003',
|
||||
type: 'Laptop',
|
||||
brand: 'Lenovo',
|
||||
model: 'ThinkPad X1 Carbon Gen 11',
|
||||
serialNumber: 'LN88231',
|
||||
status: ASSET_STATUS.AVAILABLE,
|
||||
assignedTo: null,
|
||||
assignedDate: null,
|
||||
condition: 'Good',
|
||||
purchaseDate: '2023-10-05',
|
||||
warrantyExpiry: '2026-10-05',
|
||||
cost: 138000,
|
||||
location: 'Pune',
|
||||
vendor: 'Lenovo India',
|
||||
},
|
||||
{
|
||||
id: 'AST-004',
|
||||
assetTag: 'LAP-004',
|
||||
type: 'Laptop',
|
||||
brand: 'HP',
|
||||
model: 'EliteBook 840 G9',
|
||||
serialNumber: 'HP5521XA',
|
||||
status: ASSET_STATUS.MAINTENANCE,
|
||||
assignedTo: null,
|
||||
assignedDate: null,
|
||||
condition: 'Fair',
|
||||
purchaseDate: '2022-12-01',
|
||||
warrantyExpiry: '2025-12-01',
|
||||
cost: 104000,
|
||||
location: 'Bengaluru',
|
||||
vendor: 'HP Enterprise',
|
||||
},
|
||||
{
|
||||
id: 'AST-005',
|
||||
assetTag: 'LAP-005',
|
||||
type: 'Laptop',
|
||||
brand: 'Apple',
|
||||
model: 'MacBook Air M2',
|
||||
serialNumber: 'C02YT4LM7',
|
||||
status: ASSET_STATUS.ISSUED,
|
||||
assignedTo: 'EMP-003',
|
||||
assignedDate: '2025-02-10',
|
||||
condition: 'Excellent',
|
||||
purchaseDate: '2025-01-28',
|
||||
warrantyExpiry: '2028-01-28',
|
||||
cost: 124000,
|
||||
location: 'Pune',
|
||||
vendor: 'Apple Authorised Reseller',
|
||||
},
|
||||
{
|
||||
id: 'AST-006',
|
||||
assetTag: 'MON-001',
|
||||
type: 'Monitor',
|
||||
brand: 'Dell',
|
||||
model: 'UltraSharp U2723QE',
|
||||
serialNumber: 'DMON7712',
|
||||
status: ASSET_STATUS.ISSUED,
|
||||
assignedTo: 'EMP-001',
|
||||
assignedDate: '2026-09-15',
|
||||
condition: 'Good',
|
||||
purchaseDate: '2024-03-02',
|
||||
warrantyExpiry: '2027-03-02',
|
||||
cost: 48000,
|
||||
location: 'Bengaluru',
|
||||
vendor: 'Dell India',
|
||||
},
|
||||
{
|
||||
id: 'AST-007',
|
||||
assetTag: 'MON-002',
|
||||
type: 'Monitor',
|
||||
brand: 'LG',
|
||||
model: '27UP850 4K',
|
||||
serialNumber: 'LGM90021',
|
||||
status: ASSET_STATUS.AVAILABLE,
|
||||
assignedTo: null,
|
||||
assignedDate: null,
|
||||
condition: 'Good',
|
||||
purchaseDate: '2023-07-14',
|
||||
warrantyExpiry: '2026-07-14',
|
||||
cost: 42000,
|
||||
location: 'Hyderabad',
|
||||
vendor: 'LG Electronics',
|
||||
},
|
||||
{
|
||||
id: 'AST-008',
|
||||
assetTag: 'MOB-001',
|
||||
type: 'Mobile Phone',
|
||||
brand: 'Samsung',
|
||||
model: 'Galaxy S23',
|
||||
serialNumber: 'SMG23881',
|
||||
status: ASSET_STATUS.ISSUED,
|
||||
assignedTo: 'EMP-007',
|
||||
assignedDate: '2024-08-20',
|
||||
condition: 'Good',
|
||||
purchaseDate: '2024-08-01',
|
||||
warrantyExpiry: '2026-08-01',
|
||||
cost: 79000,
|
||||
location: 'Remote',
|
||||
vendor: 'Samsung India',
|
||||
},
|
||||
{
|
||||
id: 'AST-009',
|
||||
assetTag: 'DOC-001',
|
||||
type: 'Docking Station',
|
||||
brand: 'Dell',
|
||||
model: 'WD19TBS Thunderbolt',
|
||||
serialNumber: 'DDS44120',
|
||||
status: ASSET_STATUS.AVAILABLE,
|
||||
assignedTo: null,
|
||||
assignedDate: null,
|
||||
condition: 'Good',
|
||||
purchaseDate: '2023-05-19',
|
||||
warrantyExpiry: '2026-05-19',
|
||||
cost: 22000,
|
||||
location: 'Bengaluru',
|
||||
vendor: 'Dell India',
|
||||
},
|
||||
{
|
||||
id: 'AST-010',
|
||||
assetTag: 'LAP-006',
|
||||
type: 'Laptop',
|
||||
brand: 'Dell',
|
||||
model: 'Latitude 5420',
|
||||
serialNumber: 'DL54200A',
|
||||
status: ASSET_STATUS.RETIRED,
|
||||
assignedTo: null,
|
||||
assignedDate: null,
|
||||
condition: 'Damaged',
|
||||
purchaseDate: '2020-09-15',
|
||||
warrantyExpiry: '2023-09-15',
|
||||
cost: 86000,
|
||||
location: 'Bengaluru',
|
||||
vendor: 'Dell India',
|
||||
},
|
||||
{
|
||||
id: 'AST-011',
|
||||
assetTag: 'HED-001',
|
||||
type: 'Headset',
|
||||
brand: 'Jabra',
|
||||
model: 'Evolve2 65',
|
||||
serialNumber: 'JB65T0091',
|
||||
status: ASSET_STATUS.ISSUED,
|
||||
assignedTo: 'EMP-009',
|
||||
assignedDate: '2025-04-04',
|
||||
condition: 'Good',
|
||||
purchaseDate: '2025-03-25',
|
||||
warrantyExpiry: '2027-03-25',
|
||||
cost: 18000,
|
||||
location: 'Pune',
|
||||
vendor: 'Jabra',
|
||||
},
|
||||
{
|
||||
id: 'AST-012',
|
||||
assetTag: 'LAP-007',
|
||||
type: 'Laptop',
|
||||
brand: 'Lenovo',
|
||||
model: 'ThinkPad T14 Gen 4',
|
||||
serialNumber: 'LNT14992',
|
||||
status: ASSET_STATUS.AVAILABLE,
|
||||
assignedTo: null,
|
||||
assignedDate: null,
|
||||
condition: 'New',
|
||||
purchaseDate: '2026-01-20',
|
||||
warrantyExpiry: '2029-01-20',
|
||||
cost: 115000,
|
||||
location: 'Bengaluru',
|
||||
vendor: 'Lenovo India',
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* Append-only timeline. Newest entries are added at the end; the UI sorts.
|
||||
*/
|
||||
export const assetHistory = [
|
||||
// LAP-001 — purchased, issued to Priya, returned, now issued to Rahul.
|
||||
{ id: 'H-001', assetId: 'AST-001', action: 'Purchased', date: '2024-02-12', employeeId: null, condition: 'New', notes: 'Purchased from Dell India, PO #PO-2024-118', performedBy: 'Vikram Rao' },
|
||||
{ id: 'H-002', assetId: 'AST-001', action: 'Available', date: '2024-02-14', employeeId: null, condition: 'New', notes: 'Imaged and added to inventory', performedBy: 'Vikram Rao' },
|
||||
{ id: 'H-003', assetId: 'AST-001', action: 'Issued', date: '2024-03-01', employeeId: 'EMP-002', condition: 'New', notes: 'Issued as primary work laptop', performedBy: 'Vikram Rao' },
|
||||
{ id: 'H-004', assetId: 'AST-001', action: 'Returned', date: '2026-09-10', employeeId: 'EMP-002', condition: 'Excellent', notes: 'Returned on upgrade to MacBook Pro', performedBy: 'Vikram Rao' },
|
||||
{ id: 'H-005', assetId: 'AST-001', action: 'Available', date: '2026-09-10', employeeId: null, condition: 'Excellent', notes: 'Wiped and returned to stock', performedBy: 'Vikram Rao' },
|
||||
{ id: 'H-006', assetId: 'AST-001', action: 'Issued', date: '2026-09-15', employeeId: 'EMP-001', condition: 'Excellent', notes: 'Issued as primary work laptop', performedBy: 'Vikram Rao' },
|
||||
|
||||
// LAP-002
|
||||
{ id: 'H-007', assetId: 'AST-002', action: 'Purchased', date: '2025-05-20', employeeId: null, condition: 'New', notes: 'PO #PO-2025-042', performedBy: 'Vikram Rao' },
|
||||
{ id: 'H-008', assetId: 'AST-002', action: 'Available', date: '2025-05-28', employeeId: null, condition: 'New', notes: 'Enrolled in MDM', performedBy: 'Vikram Rao' },
|
||||
{ id: 'H-009', assetId: 'AST-002', action: 'Issued', date: '2025-06-01', employeeId: 'EMP-002', condition: 'New', notes: 'Upgrade from Latitude 5440', performedBy: 'Vikram Rao' },
|
||||
|
||||
// LAP-003
|
||||
{ id: 'H-010', assetId: 'AST-003', action: 'Purchased', date: '2023-10-05', employeeId: null, condition: 'New', notes: 'PO #PO-2023-231', performedBy: 'Vikram Rao' },
|
||||
{ id: 'H-011', assetId: 'AST-003', action: 'Available', date: '2023-10-09', employeeId: null, condition: 'New', notes: 'Added to Pune stock', performedBy: 'Vikram Rao' },
|
||||
{ id: 'H-012', assetId: 'AST-003', action: 'Issued', date: '2023-11-02', employeeId: 'EMP-008', condition: 'New', notes: 'Issued on joining Marketing team', performedBy: 'Vikram Rao' },
|
||||
{ id: 'H-013', assetId: 'AST-003', action: 'Returned', date: '2025-12-18', employeeId: 'EMP-008', condition: 'Good', notes: 'Returned during exit clearance', performedBy: 'Sneha Iyer' },
|
||||
{ id: 'H-014', assetId: 'AST-003', action: 'Available', date: '2025-12-20', employeeId: null, condition: 'Good', notes: 'Wiped and returned to stock', performedBy: 'Vikram Rao' },
|
||||
|
||||
// LAP-004
|
||||
{ id: 'H-015', assetId: 'AST-004', action: 'Purchased', date: '2022-12-01', employeeId: null, condition: 'New', notes: 'PO #PO-2022-402', performedBy: 'Vikram Rao' },
|
||||
{ id: 'H-016', assetId: 'AST-004', action: 'Available', date: '2022-12-06', employeeId: null, condition: 'New', notes: 'Added to inventory', performedBy: 'Vikram Rao' },
|
||||
{ id: 'H-017', assetId: 'AST-004', action: 'Issued', date: '2023-01-16', employeeId: 'EMP-003', condition: 'New', notes: 'Issued on joining Design team', performedBy: 'Vikram Rao' },
|
||||
{ id: 'H-018', assetId: 'AST-004', action: 'Returned', date: '2025-02-08', employeeId: 'EMP-003', condition: 'Fair', notes: 'Battery swelling reported', performedBy: 'Vikram Rao' },
|
||||
{ id: 'H-019', assetId: 'AST-004', action: 'Maintenance', date: '2025-02-09', employeeId: null, condition: 'Fair', notes: 'Sent to HP service centre for battery replacement', performedBy: 'Vikram Rao' },
|
||||
|
||||
// LAP-005
|
||||
{ id: 'H-020', assetId: 'AST-005', action: 'Purchased', date: '2025-01-28', employeeId: null, condition: 'New', notes: 'PO #PO-2025-007', performedBy: 'Vikram Rao' },
|
||||
{ id: 'H-021', assetId: 'AST-005', action: 'Available', date: '2025-02-03', employeeId: null, condition: 'New', notes: 'Enrolled in MDM', performedBy: 'Vikram Rao' },
|
||||
{ id: 'H-022', assetId: 'AST-005', action: 'Issued', date: '2025-02-10', employeeId: 'EMP-003', condition: 'New', notes: 'Replacement for EliteBook 840 G9', performedBy: 'Vikram Rao' },
|
||||
|
||||
// MON-001
|
||||
{ id: 'H-023', assetId: 'AST-006', action: 'Purchased', date: '2024-03-02', employeeId: null, condition: 'New', notes: 'PO #PO-2024-140', performedBy: 'Vikram Rao' },
|
||||
{ id: 'H-024', assetId: 'AST-006', action: 'Available', date: '2024-03-04', employeeId: null, condition: 'New', notes: 'Added to Bengaluru stock', performedBy: 'Vikram Rao' },
|
||||
{ id: 'H-025', assetId: 'AST-006', action: 'Issued', date: '2026-09-15', employeeId: 'EMP-001', condition: 'Good', notes: 'Desk setup for Bengaluru office', performedBy: 'Vikram Rao' },
|
||||
|
||||
// MON-002
|
||||
{ id: 'H-026', assetId: 'AST-007', action: 'Purchased', date: '2023-07-14', employeeId: null, condition: 'New', notes: 'PO #PO-2023-180', performedBy: 'Vikram Rao' },
|
||||
{ id: 'H-027', assetId: 'AST-007', action: 'Available', date: '2023-07-18', employeeId: null, condition: 'New', notes: 'Added to Hyderabad stock', performedBy: 'Vikram Rao' },
|
||||
|
||||
// MOB-001
|
||||
{ id: 'H-028', assetId: 'AST-008', action: 'Purchased', date: '2024-08-01', employeeId: null, condition: 'New', notes: 'PO #PO-2024-260', performedBy: 'Vikram Rao' },
|
||||
{ id: 'H-029', assetId: 'AST-008', action: 'Available', date: '2024-08-12', employeeId: null, condition: 'New', notes: 'SIM activated', performedBy: 'Vikram Rao' },
|
||||
{ id: 'H-030', assetId: 'AST-008', action: 'Issued', date: '2024-08-20', employeeId: 'EMP-007', condition: 'New', notes: 'Field sales device', performedBy: 'Vikram Rao' },
|
||||
|
||||
// DOC-001
|
||||
{ id: 'H-031', assetId: 'AST-009', action: 'Purchased', date: '2023-05-19', employeeId: null, condition: 'New', notes: 'PO #PO-2023-095', performedBy: 'Vikram Rao' },
|
||||
{ id: 'H-032', assetId: 'AST-009', action: 'Available', date: '2023-05-22', employeeId: null, condition: 'New', notes: 'Added to inventory', performedBy: 'Vikram Rao' },
|
||||
|
||||
// LAP-006 — retired
|
||||
{ id: 'H-033', assetId: 'AST-010', action: 'Purchased', date: '2020-09-15', employeeId: null, condition: 'New', notes: 'PO #PO-2020-311', performedBy: 'Vikram Rao' },
|
||||
{ id: 'H-034', assetId: 'AST-010', action: 'Available', date: '2020-09-21', employeeId: null, condition: 'New', notes: 'Added to inventory', performedBy: 'Vikram Rao' },
|
||||
{ id: 'H-035', assetId: 'AST-010', action: 'Issued', date: '2020-10-01', employeeId: 'EMP-005', condition: 'New', notes: 'Issued to IT Manager', performedBy: 'Vikram Rao' },
|
||||
{ id: 'H-036', assetId: 'AST-010', action: 'Returned', date: '2025-06-30', employeeId: 'EMP-005', condition: 'Damaged', notes: 'Liquid damage to keyboard deck', performedBy: 'Vikram Rao' },
|
||||
{ id: 'H-037', assetId: 'AST-010', action: 'Retired', date: '2025-07-05', employeeId: null, condition: 'Damaged', notes: 'Beyond economical repair — scheduled for e-waste disposal', performedBy: 'Vikram Rao' },
|
||||
|
||||
// HED-001
|
||||
{ id: 'H-038', assetId: 'AST-011', action: 'Purchased', date: '2025-03-25', employeeId: null, condition: 'New', notes: 'PO #PO-2025-019', performedBy: 'Vikram Rao' },
|
||||
{ id: 'H-039', assetId: 'AST-011', action: 'Available', date: '2025-03-29', employeeId: null, condition: 'New', notes: 'Added to Pune stock', performedBy: 'Vikram Rao' },
|
||||
{ id: 'H-040', assetId: 'AST-011', action: 'Issued', date: '2025-04-04', employeeId: 'EMP-009', condition: 'New', notes: 'On-call rotation headset', performedBy: 'Vikram Rao' },
|
||||
|
||||
// LAP-007
|
||||
{ id: 'H-041', assetId: 'AST-012', action: 'Purchased', date: '2026-01-20', employeeId: null, condition: 'New', notes: 'PO #PO-2026-004', performedBy: 'Vikram Rao' },
|
||||
{ id: 'H-042', assetId: 'AST-012', action: 'Available', date: '2026-01-27', employeeId: null, condition: 'New', notes: 'Imaged and added to inventory', performedBy: 'Vikram Rao' },
|
||||
]
|
||||
|
||||
export const initialData = { employees, assets, assetHistory }
|
||||
67
src/hooks/useDetailView.js
Normal file
67
src/hooks/useDetailView.js
Normal file
@ -0,0 +1,67 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { fetchDetailView, normalizeDetailView } from '../api/detailView'
|
||||
import { useAuth } from '../store/AuthContext'
|
||||
|
||||
/**
|
||||
* Loads one record through a detail view template and normalises it.
|
||||
* Returns `{ view, loading, error, reload }`; `view` is null until loaded, and
|
||||
* `reload()` resolves with the new view (null on failure).
|
||||
* `fieldDefs` names raw field ids — see normalizeDetailView().
|
||||
* `enrich(view, instanceId)` may add to the normalised view (async ok).
|
||||
*/
|
||||
export default function useDetailView(templateUid, instanceId, { fieldDefs, enrich } = {}) {
|
||||
const navigate = useNavigate()
|
||||
const { logout } = useAuth()
|
||||
|
||||
const [view, setView] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
// Guards against a slow earlier request overwriting a newer one.
|
||||
const requestId = useRef(0)
|
||||
// Field defs are static per page; keep them out of the deps so an inline
|
||||
// array doesn't refetch on every render.
|
||||
const defsRef = useRef(fieldDefs)
|
||||
defsRef.current = fieldDefs
|
||||
const enrichRef = useRef(enrich)
|
||||
enrichRef.current = enrich
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const id = ++requestId.current
|
||||
if (!instanceId) {
|
||||
setView(null)
|
||||
setError('No employee was selected.')
|
||||
setLoading(false)
|
||||
return null
|
||||
}
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const res = await fetchDetailView(templateUid, instanceId)
|
||||
const normalized = normalizeDetailView(res, { instanceId, fieldDefs: defsRef.current })
|
||||
const next = enrichRef.current ? await enrichRef.current(normalized, instanceId) : normalized
|
||||
if (id !== requestId.current) return null
|
||||
setView(next)
|
||||
return next
|
||||
} catch (err) {
|
||||
if (id !== requestId.current) return null
|
||||
if (err?.status === 401) {
|
||||
logout()
|
||||
navigate('/login', { replace: true })
|
||||
return null
|
||||
}
|
||||
setError(err?.message || 'Could not load this record.')
|
||||
setView(null)
|
||||
return null
|
||||
} finally {
|
||||
if (id === requestId.current) setLoading(false)
|
||||
}
|
||||
}, [templateUid, instanceId, logout, navigate])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
return { view, loading, error, reload: load }
|
||||
}
|
||||
78
src/hooks/useRecordView.js
Normal file
78
src/hooks/useRecordView.js
Normal file
@ -0,0 +1,78 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { DEFAULT_PAGE_SIZE, fetchRecordView } from '../api/recordView'
|
||||
import { useAuth } from '../store/AuthContext'
|
||||
|
||||
/**
|
||||
* Loads a record view from the backend and keeps page/sort/search state.
|
||||
* Returns everything <RecordView> needs, plus `reload()` to refetch after a
|
||||
* record is created. `transform(res)` may enrich each response (async ok).
|
||||
* `fetcher(templateUid, query)` replaces the plain record view request, e.g.
|
||||
* to filter on something the server can't; it gets `query.filter`, which
|
||||
* starts as `filter` and changes with `setFilter()`.
|
||||
*/
|
||||
export default function useRecordView(
|
||||
templateUid,
|
||||
{ limit = DEFAULT_PAGE_SIZE, transform, fetcher = fetchRecordView, filter = '' } = {},
|
||||
) {
|
||||
const navigate = useNavigate()
|
||||
const { logout } = useAuth()
|
||||
// Kept out of the deps so an inline function doesn't refetch every render.
|
||||
const transformRef = useRef(transform)
|
||||
transformRef.current = transform
|
||||
const fetcherRef = useRef(fetcher)
|
||||
fetcherRef.current = fetcher
|
||||
|
||||
const [query, setQuery] = useState({ page: 1, limit, sortBy: '', sortDir: '', search: '', filter })
|
||||
const [result, setResult] = useState({ config: null, data: [], pagination: null })
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
// Guards against a slow earlier request overwriting a newer one.
|
||||
const requestId = useRef(0)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const id = ++requestId.current
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const fetched = await fetcherRef.current(templateUid, query)
|
||||
const res = transformRef.current ? await transformRef.current(fetched) : fetched
|
||||
if (id !== requestId.current) return
|
||||
setResult({
|
||||
config: res?.config || { fields: [] },
|
||||
data: Array.isArray(res?.data) ? res.data : [],
|
||||
pagination: res?.pagination || null,
|
||||
})
|
||||
} catch (err) {
|
||||
if (id !== requestId.current) return
|
||||
if (err?.status === 401) {
|
||||
logout()
|
||||
navigate('/login', { replace: true })
|
||||
return
|
||||
}
|
||||
setError(err?.message || 'Could not load records.')
|
||||
setResult({ config: { fields: [] }, data: [], pagination: null })
|
||||
} finally {
|
||||
if (id === requestId.current) setLoading(false)
|
||||
}
|
||||
}, [templateUid, query, logout, navigate])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
return {
|
||||
fields: result.config?.fields || [],
|
||||
rows: result.data,
|
||||
pagination: result.pagination,
|
||||
loading,
|
||||
error,
|
||||
query,
|
||||
setPage: (page) => setQuery((q) => ({ ...q, page })),
|
||||
setSearch: (search) => setQuery((q) => ({ ...q, search, page: 1 })),
|
||||
setSort: (sortBy, sortDir) => setQuery((q) => ({ ...q, sortBy, sortDir, page: 1 })),
|
||||
setFilter: (filter) => setQuery((q) => ({ ...q, filter, page: 1 })),
|
||||
reload: load,
|
||||
}
|
||||
}
|
||||
19
src/main.jsx
Normal file
19
src/main.jsx
Normal file
@ -0,0 +1,19 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import App from './App'
|
||||
import { AppProvider } from './store/AppContext'
|
||||
import { AuthProvider } from './store/AuthContext'
|
||||
import './styles/global.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter basename={import.meta.env.BASE_URL}>
|
||||
<AuthProvider>
|
||||
<AppProvider>
|
||||
<App />
|
||||
</AppProvider>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
289
src/pages/Dashboard.jsx
Normal file
289
src/pages/Dashboard.jsx
Normal file
@ -0,0 +1,289 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { Avatar, Button, Card, EmptyState, Icon, PageHeader } from '../components/ui'
|
||||
import Timeline from '../components/common/Timeline'
|
||||
import { fetchDashboard } from '../api/dashboard'
|
||||
import { ASSET_STATE } from '../api/assets'
|
||||
import { useAuth } from '../store/AuthContext'
|
||||
import { formatCurrency } from '../utils/format'
|
||||
|
||||
/**
|
||||
* Colour per asset state — categorical slots 1–3, fixed to the state (never to
|
||||
* its rank), so a state keeps its colour whatever the counts. Validated
|
||||
* all-pairs on the white card surface; aqua is under 3:1 there, so every
|
||||
* segment also carries a visible label in the legend.
|
||||
*/
|
||||
const STATE_COLOR = {
|
||||
[ASSET_STATE.ASSIGNED]: 'var(--series-1)',
|
||||
[ASSET_STATE.AVAILABLE]: 'var(--series-3)',
|
||||
[ASSET_STATE.CLOSED]: 'var(--series-2)',
|
||||
}
|
||||
const OTHER_COLOR = 'var(--border-strong)'
|
||||
|
||||
const pct = (n, total) => (total ? Math.round((n / total) * 100) : 0)
|
||||
|
||||
/** Overview of the inventory, read live from the asset and employee workflows. */
|
||||
export default function Dashboard() {
|
||||
const navigate = useNavigate()
|
||||
const { logout } = useAuth()
|
||||
const [data, setData] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const requestId = useRef(0)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const id = ++requestId.current
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const next = await fetchDashboard()
|
||||
if (id === requestId.current) setData(next)
|
||||
} catch (err) {
|
||||
if (id !== requestId.current) return
|
||||
if (err?.status === 401) {
|
||||
logout()
|
||||
navigate('/login', { replace: true })
|
||||
return
|
||||
}
|
||||
setError(err?.message || 'Could not load the dashboard.')
|
||||
} finally {
|
||||
if (id === requestId.current) setLoading(false)
|
||||
}
|
||||
}, [logout, navigate])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
const header = (
|
||||
<PageHeader
|
||||
title="Dashboard"
|
||||
subtitle="Asset inventory at a glance, read live from the workflows."
|
||||
actions={
|
||||
<Button icon="history" onClick={load} disabled={loading}>
|
||||
{loading ? 'Refreshing…' : 'Refresh'}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<>
|
||||
{header}
|
||||
<Card>
|
||||
{error ? (
|
||||
<EmptyState
|
||||
icon="🙁"
|
||||
title="Could not load the dashboard"
|
||||
text={error}
|
||||
action={<Button onClick={load}>Try again</Button>}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState icon="⏳" title="Loading dashboard" text="Fetching from the workflows…" />
|
||||
)}
|
||||
</Card>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const count = (state) => data.states.find((s) => s.state === state)?.count ?? 0
|
||||
|
||||
return (
|
||||
<>
|
||||
{header}
|
||||
|
||||
{error && (
|
||||
<div className="notice notice--danger" role="alert">
|
||||
<Icon name="alert" size={15} />
|
||||
<span>{error} Showing the last loaded numbers.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* A refetch keeps the previous numbers, dimmed, instead of flashing. */}
|
||||
<div className={`dash${loading ? ' dash--stale' : ''}`}>
|
||||
<div className="stat-grid">
|
||||
<StatTile label="Total assets" value={data.total} to="/assets" />
|
||||
<StatTile label="Assigned" value={count(ASSET_STATE.ASSIGNED)} to="/assign" />
|
||||
<StatTile label="Available" value={count(ASSET_STATE.AVAILABLE)} to="/assign" />
|
||||
<StatTile label="Closed (lost or retired)" value={count(ASSET_STATE.CLOSED)} to="/history" />
|
||||
<StatTile
|
||||
label="Active employees"
|
||||
value={data.employees.active}
|
||||
note={data.employees.inactive ? `${data.employees.inactive} inactive` : undefined}
|
||||
to="/employees"
|
||||
/>
|
||||
<StatTile label="Inventory value" value={formatCurrency(data.inventoryValue)} note="Sum of purchase costs" />
|
||||
</div>
|
||||
|
||||
<div className="dash-grid">
|
||||
<Card title="Asset status" subtitle={`Share of all ${data.total} assets by workflow state`}>
|
||||
<StateMeter states={data.states} total={data.total} />
|
||||
</Card>
|
||||
|
||||
<Card title="Assets by category" subtitle="Number of assets in each category">
|
||||
<CategoryBars categories={data.categories} />
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="Who has assets now"
|
||||
subtitle="Employees holding assigned assets"
|
||||
actions={
|
||||
<Link className="link text-sm" to="/assign">
|
||||
View all
|
||||
</Link>
|
||||
}
|
||||
flush
|
||||
>
|
||||
<HolderList holders={data.holders} />
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="Recent activity"
|
||||
subtitle="Latest purchases, assignments, returns and losses"
|
||||
actions={
|
||||
<Link className="link text-sm" to="/history">
|
||||
Full history
|
||||
</Link>
|
||||
}
|
||||
flush
|
||||
>
|
||||
{data.recent.length ? (
|
||||
<Timeline
|
||||
events={data.recent.map((e) => ({
|
||||
...e,
|
||||
title: [e.action, e.asset_code].filter(Boolean).join(' · '),
|
||||
meta: [e.employee, e.meta].filter(Boolean).join(' · ') || undefined,
|
||||
}))}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState icon="🕘" title="No activity yet" text="Asset events show up here." />
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** One headline number. Proportional figures; the label says what it counts. */
|
||||
function StatTile({ label, value, note, to }) {
|
||||
const body = (
|
||||
<>
|
||||
<span className="stat__label">{label}</span>
|
||||
<span className="stat__value">{typeof value === 'number' ? value.toLocaleString('en-IN') : value}</span>
|
||||
{note && <span className="stat__note">{note}</span>}
|
||||
</>
|
||||
)
|
||||
return to ? (
|
||||
<Link className="stat stat--link" to={to}>
|
||||
{body}
|
||||
</Link>
|
||||
) : (
|
||||
<div className="stat">{body}</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Part-to-whole of the asset states: one stacked bar with 2px surface gaps,
|
||||
* and a legend that always shows each state's count and share, so no value
|
||||
* lives only in the tooltip.
|
||||
*/
|
||||
function StateMeter({ states, total }) {
|
||||
const [tip, setTip] = useState(null)
|
||||
const shown = states.filter((s) => s.count > 0)
|
||||
if (!total) return <EmptyState icon="📦" title="No assets yet" text="Add an asset to see its status here." />
|
||||
|
||||
const show = (s, e) => {
|
||||
const box = e.currentTarget.parentElement.getBoundingClientRect()
|
||||
const mark = e.currentTarget.getBoundingClientRect()
|
||||
const x = e.clientX ?? mark.left + mark.width / 2
|
||||
setTip({ s, x: x - box.left })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="meter-wrap">
|
||||
<div className="meter" role="img" aria-label={shown.map((s) => `${s.state} ${s.count}`).join(', ')}>
|
||||
{shown.map((s) => (
|
||||
<span
|
||||
key={s.state}
|
||||
className="meter__seg"
|
||||
style={{ flexGrow: s.count, background: STATE_COLOR[s.state] ?? OTHER_COLOR }}
|
||||
tabIndex={0}
|
||||
aria-label={`${s.state}: ${s.count} (${pct(s.count, total)}%)`}
|
||||
onPointerMove={(e) => show(s, e)}
|
||||
onPointerLeave={() => setTip(null)}
|
||||
onFocus={(e) => show(s, e)}
|
||||
onBlur={() => setTip(null)}
|
||||
/>
|
||||
))}
|
||||
{tip && (
|
||||
<div className="chart-tip" style={{ left: tip.x }} role="presentation">
|
||||
<strong>{tip.s.count}</strong> <span>{tip.s.state} · {pct(tip.s.count, total)}%</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ul className="legend">
|
||||
{states.map((s) => (
|
||||
<li key={s.state} className="legend__item">
|
||||
<span className="legend__swatch" style={{ background: STATE_COLOR[s.state] ?? OTHER_COLOR }} />
|
||||
<span className="legend__label">{s.state}</span>
|
||||
<span className="legend__value">
|
||||
{s.count} · {pct(s.count, total)}%
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Horizontal bars, one series (slot 1), most first. Each bar's value sits at
|
||||
* its tip, so the chart is also its own table; hover lifts the bar.
|
||||
*/
|
||||
function CategoryBars({ categories }) {
|
||||
if (!categories.length) return <EmptyState icon="📦" title="No assets yet" />
|
||||
const max = Math.max(...categories.map((c) => c.count))
|
||||
|
||||
return (
|
||||
<ul className="hbars">
|
||||
{categories.map((c) => (
|
||||
<li
|
||||
key={c.label}
|
||||
className="hbars__row"
|
||||
tabIndex={0}
|
||||
aria-label={`${c.label}: ${c.count}`}
|
||||
title={`${c.label}: ${c.count} asset${c.count === 1 ? '' : 's'}`}
|
||||
>
|
||||
<span className="hbars__label">{c.label}</span>
|
||||
<span className="hbars__track">
|
||||
<span className="hbars__bar" style={{ width: `${(c.count / max) * 100}%` }} />
|
||||
<span className="hbars__value">{c.count}</span>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
/** Employees with assets right now, most first. */
|
||||
function HolderList({ holders }) {
|
||||
if (!holders.length) {
|
||||
return <EmptyState icon="🙌" title="Nobody has an asset" text="Assigned assets show up here." />
|
||||
}
|
||||
return (
|
||||
<ul className="holders">
|
||||
{holders.slice(0, 6).map((h) => (
|
||||
<li key={h.id} className="holders__item">
|
||||
<Avatar name={h.name} />
|
||||
<span className="holders__name">{h.name}</span>
|
||||
<span className="holders__count">
|
||||
{h.count} asset{h.count === 1 ? '' : 's'}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
20
src/pages/Placeholder.jsx
Normal file
20
src/pages/Placeholder.jsx
Normal file
@ -0,0 +1,20 @@
|
||||
import { Card, EmptyState, PageHeader } from '../components/ui'
|
||||
|
||||
/**
|
||||
* Temporary stand-in for modules that are still on the build plan, so the
|
||||
* sidebar and cross-links stay navigable while we build step by step.
|
||||
*/
|
||||
export default function Placeholder({ title, subtitle, step }) {
|
||||
return (
|
||||
<>
|
||||
<PageHeader title={title} subtitle={subtitle} />
|
||||
<Card>
|
||||
<EmptyState
|
||||
icon="🚧"
|
||||
title="Not built yet"
|
||||
text={`This module is step ${step} of the build plan.`}
|
||||
/>
|
||||
</Card>
|
||||
</>
|
||||
)
|
||||
}
|
||||
153
src/pages/assets/AddAssetModal.jsx
Normal file
153
src/pages/assets/AddAssetModal.jsx
Normal file
@ -0,0 +1,153 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Button, Field, Icon, Modal, SelectInput } from '../../components/ui'
|
||||
import ActivityFields from '../../components/activity/ActivityFields'
|
||||
import { ASSET_FIELDS, createAsset, fetchAssignableEmployees } from '../../api/assets'
|
||||
import { emptyValues, validateActivity } from '../../api/activityForm'
|
||||
import { useAuth } from '../../store/AuthContext'
|
||||
|
||||
/**
|
||||
* "Add Asset" dialog. Fields are rendered straight from the Zino builder
|
||||
* definition in api/assets.js, so the form always matches the activity.
|
||||
* "Asset Owner" is a lookup, so it is picked from a list of active employees.
|
||||
*/
|
||||
export default function AddAssetModal({ open, onClose, onCreated }) {
|
||||
const navigate = useNavigate()
|
||||
const { logout } = useAuth()
|
||||
|
||||
const [values, setValues] = useState(() => emptyValues(ASSET_FIELDS))
|
||||
const [employees, setEmployees] = useState([])
|
||||
const [loadingEmployees, setLoadingEmployees] = useState(false)
|
||||
const [ownerId, setOwnerId] = useState('')
|
||||
const [errors, setErrors] = useState({})
|
||||
const [serverError, setServerError] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
// A fresh employee list each time the dialog opens.
|
||||
useEffect(() => {
|
||||
if (!open) return undefined
|
||||
let cancelled = false
|
||||
setLoadingEmployees(true)
|
||||
fetchAssignableEmployees()
|
||||
.then((list) => !cancelled && setEmployees(list))
|
||||
.catch((err) => {
|
||||
if (cancelled) return
|
||||
if (err?.status === 401) {
|
||||
logout()
|
||||
navigate('/login', { replace: true })
|
||||
return
|
||||
}
|
||||
setServerError(err?.message || 'Could not load the employee list.')
|
||||
})
|
||||
.finally(() => !cancelled && setLoadingEmployees(false))
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [open, logout, navigate])
|
||||
|
||||
function reset() {
|
||||
setValues(emptyValues(ASSET_FIELDS))
|
||||
setOwnerId('')
|
||||
setErrors({})
|
||||
setServerError('')
|
||||
setSubmitting(false)
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (submitting) return
|
||||
reset()
|
||||
onClose?.()
|
||||
}
|
||||
|
||||
function handleChange(id, value) {
|
||||
setValues((v) => ({ ...v, [id]: value }))
|
||||
if (errors[id]) setErrors((er) => ({ ...er, [id]: undefined }))
|
||||
if (serverError) setServerError('')
|
||||
}
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault()
|
||||
const nextErrors = validateActivity(ASSET_FIELDS, values)
|
||||
setErrors(nextErrors)
|
||||
if (Object.keys(nextErrors).length) return
|
||||
|
||||
setSubmitting(true)
|
||||
setServerError('')
|
||||
try {
|
||||
const owner = employees.find((emp) => String(emp.id) === ownerId) ?? null
|
||||
const response = await createAsset(values, owner)
|
||||
onCreated?.(values, response)
|
||||
reset()
|
||||
onClose?.()
|
||||
} catch (err) {
|
||||
if (err?.status === 401) {
|
||||
logout()
|
||||
navigate('/login', { replace: true })
|
||||
return
|
||||
}
|
||||
setServerError(err?.message || 'Could not create the asset. Please try again.')
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={close}
|
||||
title="Add Asset"
|
||||
subtitle="Starts the asset onboarding workflow."
|
||||
size="lg"
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={close} disabled={submitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form="add-asset-form"
|
||||
variant="primary"
|
||||
icon={submitting ? undefined : 'plus'}
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? 'Creating…' : 'Create Asset'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{serverError && (
|
||||
<div className="auth__alert" role="alert">
|
||||
<Icon name="alert" size={15} />
|
||||
<span>{serverError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form id="add-asset-form" className="form__grid" onSubmit={handleSubmit} noValidate>
|
||||
<ActivityFields
|
||||
fields={ASSET_FIELDS}
|
||||
values={values}
|
||||
errors={errors}
|
||||
onChange={handleChange}
|
||||
disabled={submitting}
|
||||
/>
|
||||
<Field label="Asset Owner">
|
||||
{(id) => (
|
||||
<SelectInput
|
||||
id={id}
|
||||
value={ownerId}
|
||||
onChange={(e) => {
|
||||
setOwnerId(e.target.value)
|
||||
if (serverError) setServerError('')
|
||||
}}
|
||||
options={employees.map((emp) => ({
|
||||
value: String(emp.id),
|
||||
label: emp.code ? `${emp.name} (${emp.code})` : emp.name,
|
||||
}))}
|
||||
placeholder={loadingEmployees ? 'Loading employees…' : 'Select employee'}
|
||||
disabled={submitting || loadingEmployees}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
91
src/pages/assets/AssetActivityList.jsx
Normal file
91
src/pages/assets/AssetActivityList.jsx
Normal file
@ -0,0 +1,91 @@
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Card, PageHeader, SelectInput } from '../../components/ui'
|
||||
import RecordView from '../../components/activity/RecordView'
|
||||
import useRecordView from '../../hooks/useRecordView'
|
||||
import { RECORD_VIEWS } from '../../api/recordView'
|
||||
import {
|
||||
ASSIGNMENT_FILTERS,
|
||||
HISTORY_FILTERS,
|
||||
RETURN_FILTERS,
|
||||
fetchAssignmentListPage,
|
||||
fetchHistoryListPage,
|
||||
fetchReturnListPage,
|
||||
} from '../../api/assets'
|
||||
|
||||
const VIEW = RECORD_VIEWS.assets
|
||||
|
||||
/**
|
||||
* Asset lists read from the asset record view, with one activity's values
|
||||
* added as columns and filtered by state (see fetchAssetActivityPage).
|
||||
*/
|
||||
const LISTS = {
|
||||
assign: {
|
||||
title: 'Asset Assignment',
|
||||
subtitle: 'Which employee has which asset, read live from the asset workflow.',
|
||||
fetcher: fetchAssignmentListPage,
|
||||
filters: ASSIGNMENT_FILTERS,
|
||||
empty: {
|
||||
assigned: 'No assets are assigned right now.',
|
||||
available: 'No assets are available right now.',
|
||||
},
|
||||
},
|
||||
return: {
|
||||
title: 'Asset Return',
|
||||
subtitle: 'Assets waiting to come back, and the ones already returned.',
|
||||
fetcher: fetchReturnListPage,
|
||||
filters: RETURN_FILTERS,
|
||||
empty: {
|
||||
assigned: 'No assets are out with employees right now.',
|
||||
returned: 'No assets have been returned yet.',
|
||||
},
|
||||
},
|
||||
history: {
|
||||
title: 'Asset History',
|
||||
subtitle:
|
||||
'Every purchase, assignment, return and loss, newest first. Each asset shows its latest ' +
|
||||
'assignment and return.',
|
||||
fetcher: fetchHistoryListPage,
|
||||
filters: HISTORY_FILTERS,
|
||||
// One row per event, so several rows share an asset's instance_id.
|
||||
rowKey: 'event_id',
|
||||
empty: { all: 'No asset events yet.' },
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes: /assign, /return and /history (kind of the same name). A row opens
|
||||
* the asset's details, where its activities (Assign, Return, Lost) run.
|
||||
*/
|
||||
export default function AssetActivityList({ kind }) {
|
||||
const list = LISTS[kind]
|
||||
const navigate = useNavigate()
|
||||
const view = useRecordView(VIEW.templateUid, {
|
||||
fetcher: list.fetcher,
|
||||
filter: list.filters[0].value,
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title={list.title} subtitle={list.subtitle} />
|
||||
|
||||
<Card flush>
|
||||
<RecordView
|
||||
{...view}
|
||||
rowKey={list.rowKey ?? VIEW.rowKey}
|
||||
emptyText={list.empty[view.query.filter] ?? 'No assets yet.'}
|
||||
onRowClick={(row) => navigate(`/assets/${encodeURIComponent(row.instance_id)}`)}
|
||||
toolbar={
|
||||
<div className="filter">
|
||||
<SelectInput
|
||||
value={view.query.filter}
|
||||
onChange={(e) => view.setFilter(e.target.value)}
|
||||
options={list.filters}
|
||||
aria-label="Show assets"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
</>
|
||||
)
|
||||
}
|
||||
240
src/pages/assets/AssetActivityModal.jsx
Normal file
240
src/pages/assets/AssetActivityModal.jsx
Normal file
@ -0,0 +1,240 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Button, Field, Icon, Modal, SelectInput } from '../../components/ui'
|
||||
import ActivityFields from '../../components/activity/ActivityFields'
|
||||
import {
|
||||
ASSET_ASSIGN_FIELDS,
|
||||
ASSET_LOST_FIELDS,
|
||||
ASSET_REASSIGN_FIELDS,
|
||||
ASSET_RETURN_FIELDS,
|
||||
assignAsset,
|
||||
fetchAssignableEmployees,
|
||||
reassignAsset,
|
||||
reportAssetLost,
|
||||
returnAsset,
|
||||
} from '../../api/assets'
|
||||
import { emptyValues, validateActivity } from '../../api/activityForm'
|
||||
import { useAuth } from '../../store/AuthContext'
|
||||
|
||||
/**
|
||||
* The asset activities that run against one existing asset and carry an
|
||||
* employee lookup. The employee is picked from a list of active employees; the
|
||||
* other fields come from api/assets.js, so each form matches its activity.
|
||||
* `employeeRequired: false` lets the form run without one; `warning` is shown
|
||||
* above the form for an activity that can't be undone.
|
||||
*/
|
||||
const ACTIVITIES = {
|
||||
assign: {
|
||||
title: 'Assign Asset',
|
||||
subtitle: 'Runs the ASSIGNED activity in the asset workflow.',
|
||||
fields: ASSET_ASSIGN_FIELDS,
|
||||
employeeLabel: 'Employee data',
|
||||
employeeError: 'Choose who gets this asset.',
|
||||
submit: 'Assign',
|
||||
submitting: 'Assigning…',
|
||||
failed: 'Could not assign the asset. Please try again.',
|
||||
run: assignAsset,
|
||||
},
|
||||
reassign: {
|
||||
title: 'Reassign Asset',
|
||||
subtitle: 'Runs the Reassigned activity in the asset workflow. The asset stays Assigned.',
|
||||
fields: ASSET_REASSIGN_FIELDS,
|
||||
employeeLabel: 'Employee data',
|
||||
employeeError: 'Choose who gets this asset next.',
|
||||
submit: 'Reassign',
|
||||
submitting: 'Reassigning…',
|
||||
failed: 'Could not reassign the asset. Please try again.',
|
||||
run: reassignAsset,
|
||||
},
|
||||
return: {
|
||||
title: 'Return Asset',
|
||||
subtitle: 'Runs the RETURNED activity in the asset workflow.',
|
||||
fields: ASSET_RETURN_FIELDS,
|
||||
employeeLabel: 'Returned by',
|
||||
employeeError: 'Choose who returned this asset.',
|
||||
submit: 'Return',
|
||||
submitting: 'Returning…',
|
||||
failed: 'Could not return the asset. Please try again.',
|
||||
run: returnAsset,
|
||||
},
|
||||
lost: {
|
||||
title: 'Report Asset Lost',
|
||||
subtitle: 'Runs the Lost activity in the asset workflow.',
|
||||
fields: ASSET_LOST_FIELDS,
|
||||
employeeLabel: 'Last owner',
|
||||
employeeRequired: false,
|
||||
warning:
|
||||
'This closes the asset for good: once it is marked lost it can no longer be assigned or ' +
|
||||
'returned. This cannot be undone.',
|
||||
variant: 'danger',
|
||||
submit: 'Mark as Lost',
|
||||
submitting: 'Saving…',
|
||||
failed: 'Could not report the asset lost. Please try again.',
|
||||
run: reportAssetLost,
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Dialog for one asset activity. `kind` is a key of ACTIVITIES;
|
||||
* `defaultEmployeeId` preselects an employee (e.g. the assignee on return).
|
||||
*/
|
||||
export default function AssetActivityModal({
|
||||
kind,
|
||||
open,
|
||||
instanceId,
|
||||
defaultEmployeeId,
|
||||
onClose,
|
||||
onDone,
|
||||
}) {
|
||||
const activity = ACTIVITIES[kind]
|
||||
const employeeRequired = activity.employeeRequired !== false
|
||||
const navigate = useNavigate()
|
||||
const { logout } = useAuth()
|
||||
|
||||
const [employees, setEmployees] = useState([])
|
||||
const [loadingEmployees, setLoadingEmployees] = useState(false)
|
||||
const [employeeId, setEmployeeId] = useState('')
|
||||
const [values, setValues] = useState(() => emptyValues(activity.fields))
|
||||
const [errors, setErrors] = useState({})
|
||||
const [serverError, setServerError] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
// Start from a blank form and a fresh employee list each time the dialog opens.
|
||||
useEffect(() => {
|
||||
if (!open) return undefined
|
||||
setEmployeeId(defaultEmployeeId != null ? String(defaultEmployeeId) : '')
|
||||
setValues(emptyValues(activity.fields))
|
||||
setErrors({})
|
||||
setServerError('')
|
||||
setSubmitting(false)
|
||||
|
||||
let cancelled = false
|
||||
setLoadingEmployees(true)
|
||||
fetchAssignableEmployees()
|
||||
.then((list) => !cancelled && setEmployees(list))
|
||||
.catch((err) => {
|
||||
if (cancelled) return
|
||||
if (err?.status === 401) {
|
||||
logout()
|
||||
navigate('/login', { replace: true })
|
||||
return
|
||||
}
|
||||
setServerError(err?.message || 'Could not load the employee list.')
|
||||
})
|
||||
.finally(() => !cancelled && setLoadingEmployees(false))
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [open, activity, defaultEmployeeId, logout, navigate])
|
||||
|
||||
function close() {
|
||||
if (submitting) return
|
||||
onClose?.()
|
||||
}
|
||||
|
||||
function handleChange(id, value) {
|
||||
setValues((v) => ({ ...v, [id]: value }))
|
||||
if (errors[id]) setErrors((er) => ({ ...er, [id]: undefined }))
|
||||
if (serverError) setServerError('')
|
||||
}
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault()
|
||||
if (instanceId == null || instanceId === '') {
|
||||
setServerError('This asset has no instance id, so the activity cannot run.')
|
||||
return
|
||||
}
|
||||
const employee = employees.find((emp) => String(emp.id) === employeeId) ?? null
|
||||
const nextErrors = validateActivity(activity.fields, values)
|
||||
if (!employee && employeeRequired) nextErrors.employee = activity.employeeError
|
||||
setErrors(nextErrors)
|
||||
if (Object.keys(nextErrors).length) return
|
||||
|
||||
setSubmitting(true)
|
||||
setServerError('')
|
||||
try {
|
||||
const response = await activity.run(instanceId, values, employee)
|
||||
onDone?.(employee, response)
|
||||
onClose?.()
|
||||
} catch (err) {
|
||||
if (err?.status === 401) {
|
||||
logout()
|
||||
navigate('/login', { replace: true })
|
||||
return
|
||||
}
|
||||
setServerError(err?.message || activity.failed)
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const employeeOptions = employees.map((emp) => ({
|
||||
value: String(emp.id),
|
||||
label: emp.code ? `${emp.name} (${emp.code})` : emp.name,
|
||||
}))
|
||||
const formId = `${kind}-asset-form`
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={close}
|
||||
title={activity.title}
|
||||
subtitle={activity.subtitle}
|
||||
size="lg"
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={close} disabled={submitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form={formId}
|
||||
variant={activity.variant ?? 'primary'}
|
||||
icon={submitting ? undefined : 'check'}
|
||||
disabled={submitting || loadingEmployees}
|
||||
>
|
||||
{submitting ? activity.submitting : activity.submit}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{activity.warning && (
|
||||
<div className="notice notice--warning" role="note">
|
||||
<Icon name="alert" size={15} />
|
||||
<span>{activity.warning}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{serverError && (
|
||||
<div className="auth__alert" role="alert">
|
||||
<Icon name="alert" size={15} />
|
||||
<span>{serverError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form id={formId} className="form__grid" onSubmit={handleSubmit} noValidate>
|
||||
<Field label={activity.employeeLabel} required={employeeRequired} error={errors.employee}>
|
||||
{(id) => (
|
||||
<SelectInput
|
||||
id={id}
|
||||
value={employeeId}
|
||||
onChange={(e) => {
|
||||
setEmployeeId(e.target.value)
|
||||
if (errors.employee) setErrors((er) => ({ ...er, employee: undefined }))
|
||||
}}
|
||||
options={employeeOptions}
|
||||
placeholder={loadingEmployees ? 'Loading employees…' : 'Select employee'}
|
||||
disabled={submitting || loadingEmployees}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<ActivityFields
|
||||
fields={activity.fields}
|
||||
values={values}
|
||||
errors={errors}
|
||||
onChange={handleChange}
|
||||
disabled={submitting}
|
||||
/>
|
||||
</form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
393
src/pages/assets/AssetDetails.jsx
Normal file
393
src/pages/assets/AssetDetails.jsx
Normal file
@ -0,0 +1,393 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import {
|
||||
AssetStateBadge,
|
||||
BackLink,
|
||||
Button,
|
||||
Card,
|
||||
EmptyState,
|
||||
Icon,
|
||||
InfoList,
|
||||
InfoRow,
|
||||
PageHeader,
|
||||
} from '../../components/ui'
|
||||
import FieldValue, { formatMeta } from '../../components/activity/FieldValue'
|
||||
import Timeline from '../../components/common/Timeline'
|
||||
import { instanceState } from '../../api/activityForm'
|
||||
import {
|
||||
ASSET_ASSIGN_FIELDS,
|
||||
ASSET_LOST_FIELDS,
|
||||
ASSET_REASSIGN_FIELDS,
|
||||
ASSET_RETURN_FIELDS,
|
||||
ASSET_STATE,
|
||||
ASSIGN_EMPLOYEE_FIELD,
|
||||
LOST_EMPLOYEE_FIELD,
|
||||
REASSIGN_EMPLOYEE_FIELD,
|
||||
RETURN_EMPLOYEE_FIELD,
|
||||
assetCode,
|
||||
assetEvents,
|
||||
assetFieldDef,
|
||||
assetInitValue,
|
||||
currentAssignment,
|
||||
fetchAssetInstance,
|
||||
isAssetState,
|
||||
lookupName,
|
||||
optionLabel,
|
||||
withAssetFormColumns,
|
||||
} from '../../api/assets'
|
||||
import { useAuth } from '../../store/AuthContext'
|
||||
import AssetActivityModal from './AssetActivityModal'
|
||||
|
||||
/** Keys shown as record info rather than as asset fields. */
|
||||
const META_KEY = /^(instance_id|current_state_name|(created|updated|modified)_\w+)$/i
|
||||
|
||||
/**
|
||||
* Asset details: the asset's INIT values, its latest assignment and return,
|
||||
* and its workflow state. There is no asset detail view template, so the record comes
|
||||
* from the asset record view (see fetchAssetInstance).
|
||||
* Route: /assets/:assetId — the asset's workflow instance id.
|
||||
*/
|
||||
export default function AssetDetails() {
|
||||
const { assetId } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const { logout } = useAuth()
|
||||
|
||||
const [data, setData] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
// The activity dialog that's open: 'assign', 'reassign', 'return', 'lost' or null.
|
||||
const [activity, setActivity] = useState(null)
|
||||
const [notice, setNotice] = useState(null)
|
||||
|
||||
// Guards against a slow earlier request overwriting a newer one.
|
||||
const requestId = useRef(0)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const id = ++requestId.current
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const res = await fetchAssetInstance(assetId)
|
||||
if (id !== requestId.current) return
|
||||
if (!res.row) setError('No asset in the workflow has this id.')
|
||||
setData(res.row ? res : null)
|
||||
} catch (err) {
|
||||
if (id !== requestId.current) return
|
||||
if (err?.status === 401) {
|
||||
logout()
|
||||
navigate('/login', { replace: true })
|
||||
return
|
||||
}
|
||||
setError(err?.message || 'Could not load this asset.')
|
||||
setData(null)
|
||||
} finally {
|
||||
if (id === requestId.current) setLoading(false)
|
||||
}
|
||||
}, [assetId, logout, navigate])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
const back = () => navigate(-1)
|
||||
|
||||
if (loading && !data) {
|
||||
return (
|
||||
<>
|
||||
<BackLink onClick={back}>Back</BackLink>
|
||||
<Card>
|
||||
<EmptyState icon="⏳" title="Loading asset" text="Fetching from the workflow…" />
|
||||
</Card>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<>
|
||||
<BackLink onClick={back}>Back</BackLink>
|
||||
<Card>
|
||||
<EmptyState
|
||||
icon="🙁"
|
||||
title="Could not load this asset"
|
||||
text={error || 'The asset view returned nothing for this record.'}
|
||||
action={
|
||||
<div className="row">
|
||||
<Button onClick={load}>Try again</Button>
|
||||
<Button variant="primary" onClick={() => navigate('/assets')}>
|
||||
Back to Assets
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const { config, row } = data
|
||||
|
||||
// The asset view's own columns, in its order and with its labels, then any
|
||||
// Add Asset field the view has no column for.
|
||||
const withForm = withAssetFormColumns({ config, data: [row] })
|
||||
const formRow = withForm.data[0]
|
||||
const assetFields = (withForm.config?.fields ?? [])
|
||||
.filter((f) => f.field_key !== 'id' && !META_KEY.test(f.field_key))
|
||||
.map((f) => {
|
||||
const label = String(f.output_label || f.field_key).trim()
|
||||
const def = assetFieldDef(f.field_key, label)
|
||||
return {
|
||||
key: f.field_key,
|
||||
label,
|
||||
type: f.data_type,
|
||||
slots: f.slots ?? def?.slots,
|
||||
otherSlot: f.otherSlot ?? def?.otherSlot,
|
||||
// A select shows its option label ("In use"), not its value ("in_use").
|
||||
value: optionLabel(def, formRow[f.field_key]),
|
||||
}
|
||||
})
|
||||
|
||||
const code = assetCode(config, row)
|
||||
const title = code || 'Asset'
|
||||
const subtitle = [assetInitValue(config, row, 'Category'), assetInitValue(config, row, 'Model')]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
// Newest first, like the Asset History page.
|
||||
const history = assetEvents(config, row)
|
||||
.reverse()
|
||||
.map((e) => ({
|
||||
...e,
|
||||
title: e.employee ? `${e.action} · ${e.employee}` : e.action,
|
||||
meta: e.meta || undefined,
|
||||
}))
|
||||
|
||||
const state = instanceState(row)
|
||||
const isAssigned = isAssetState(row, ASSET_STATE.ASSIGNED)
|
||||
const isAvailable = isAssetState(row, ASSET_STATE.AVAILABLE)
|
||||
// ASSIGNED runs in Available; Reassigned and RETURNED in Assigned; Lost in
|
||||
// either. If the view sends no state, let the runtime decide rather than
|
||||
// hiding the buttons.
|
||||
const canAssign = !state || isAvailable
|
||||
const canReassign = !state || isAssigned
|
||||
const canReturn = !state || isAssigned
|
||||
const canReportLost = !state || isAvailable || isAssigned
|
||||
|
||||
// Assigned or reassigned, whichever is newer, is who has it now.
|
||||
const current = currentAssignment(row)
|
||||
const employee = current.name
|
||||
const toFields = (defs) =>
|
||||
defs.map((f) => ({ key: f.id, label: f.label, type: f.type, value: row[f.id] }))
|
||||
const assignmentFields = [
|
||||
{ key: ASSIGN_EMPLOYEE_FIELD, label: 'Employee data', type: 'text', value: lookupName(row, ASSIGN_EMPLOYEE_FIELD) },
|
||||
...toFields(ASSET_ASSIGN_FIELDS),
|
||||
]
|
||||
const reassignmentFields = [
|
||||
{ key: REASSIGN_EMPLOYEE_FIELD, label: 'Employee data', type: 'text', value: lookupName(row, REASSIGN_EMPLOYEE_FIELD) },
|
||||
...toFields(ASSET_REASSIGN_FIELDS),
|
||||
]
|
||||
const returnFields = [
|
||||
{ key: RETURN_EMPLOYEE_FIELD, label: 'Returned by', type: 'text', value: lookupName(row, RETURN_EMPLOYEE_FIELD) },
|
||||
...toFields(ASSET_RETURN_FIELDS),
|
||||
]
|
||||
const lostFields = [
|
||||
{ key: LOST_EMPLOYEE_FIELD, label: 'Last owner', type: 'text', value: lookupName(row, LOST_EMPLOYEE_FIELD) },
|
||||
...toFields(ASSET_LOST_FIELDS),
|
||||
]
|
||||
const hasValues = (fields) => fields.some((f) => f.value != null && f.value !== '')
|
||||
|
||||
const meta = Object.keys(row)
|
||||
.filter((k) => META_KEY.test(k) && row[k] != null && row[k] !== '' && typeof row[k] !== 'object')
|
||||
.map((k) => ({ key: k, label: k.replace(/_/g, ' '), value: row[k] }))
|
||||
|
||||
function openActivity(kind) {
|
||||
setNotice(null)
|
||||
setActivity(kind)
|
||||
}
|
||||
|
||||
/** `emp` is the employee picked in the dialog; null when Lost ran without one. */
|
||||
function handleDone(emp) {
|
||||
const text = {
|
||||
assign: `Asset assigned to ${emp?.name}.`,
|
||||
reassign: `Asset reassigned to ${emp?.name}.`,
|
||||
return: `Asset returned by ${emp?.name}.`,
|
||||
lost: 'Asset was reported lost and closed.',
|
||||
}[activity]
|
||||
setNotice({ tone: 'success', text })
|
||||
load()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<BackLink onClick={back}>Back</BackLink>
|
||||
|
||||
<PageHeader
|
||||
title={title}
|
||||
subtitle={subtitle || undefined}
|
||||
actions={
|
||||
<>
|
||||
<Button icon="history" onClick={load} disabled={loading}>
|
||||
{loading ? 'Refreshing…' : 'Refresh'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
icon="alert"
|
||||
onClick={() => openActivity('lost')}
|
||||
disabled={!canReportLost}
|
||||
>
|
||||
Report Lost
|
||||
</Button>
|
||||
<Button icon="return" onClick={() => openActivity('return')} disabled={!canReturn}>
|
||||
Return Asset
|
||||
</Button>
|
||||
<Button icon="assign" onClick={() => openActivity('reassign')} disabled={!canReassign}>
|
||||
Reassign
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
icon="check"
|
||||
onClick={() => openActivity('assign')}
|
||||
disabled={!canAssign}
|
||||
>
|
||||
Assign Asset
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{!canAssign && !canReturn && !canReportLost && (
|
||||
<div className="notice notice--warning" role="status">
|
||||
<Icon name="alert" size={15} />
|
||||
<span>
|
||||
This asset is in "{state}", so its workflow has finished. It can't be assigned,
|
||||
returned or reported lost any more.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{notice && (
|
||||
<div className={`notice notice--${notice.tone}`} role="status">
|
||||
<Icon name={notice.tone === 'success' ? 'check' : 'alert'} size={15} />
|
||||
<span>{notice.text}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="notice__close"
|
||||
onClick={() => setNotice(null)}
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<Icon name="close" size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="detail-grid">
|
||||
{/* ---------- Left: state + record info ---------- */}
|
||||
<div className="stack">
|
||||
<Card title="Status" flush>
|
||||
<InfoList>
|
||||
<InfoRow label="State">{state ? <AssetStateBadge state={state} /> : null}</InfoRow>
|
||||
{/* After a return the old assignee stays on the record; it's not current. */}
|
||||
<InfoRow label="Assigned to">{isAssigned && employee ? employee : null}</InfoRow>
|
||||
</InfoList>
|
||||
</Card>
|
||||
|
||||
<Card title="History">
|
||||
{history.length ? (
|
||||
<Timeline events={history} />
|
||||
) : (
|
||||
<EmptyState icon="🕘" title="No history yet" text="Nothing has happened to this asset yet." />
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{meta.length > 0 && (
|
||||
<Card title="Record info" flush>
|
||||
<InfoList>
|
||||
{meta.map((m) => (
|
||||
<InfoRow key={m.key} label={m.label}>
|
||||
<span className="cell-mono">{formatMeta(m.value)}</span>
|
||||
</InfoRow>
|
||||
))}
|
||||
</InfoList>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ---------- Right: asset + assignment fields ---------- */}
|
||||
<div className="stack">
|
||||
<Card title="Asset details" flush>
|
||||
<DetailFields fields={assetFields} />
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title={isAssigned && current.source === 'assign' ? 'Assignment' : 'Last assignment'}
|
||||
flush
|
||||
>
|
||||
{hasValues(assignmentFields) ? (
|
||||
<DetailFields fields={assignmentFields} />
|
||||
) : (
|
||||
<EmptyState
|
||||
icon="📦"
|
||||
title="Not assigned"
|
||||
text="This asset hasn't been assigned to anyone yet."
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{hasValues(reassignmentFields) && (
|
||||
<Card
|
||||
title={isAssigned && current.source === 'reassign' ? 'Reassignment' : 'Last reassignment'}
|
||||
flush
|
||||
>
|
||||
<DetailFields fields={reassignmentFields} />
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{hasValues(returnFields) && (
|
||||
<Card title="Last return" flush>
|
||||
<DetailFields fields={returnFields} />
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{hasValues(lostFields) && (
|
||||
<Card title="Loss report" flush>
|
||||
<DetailFields fields={lostFields} />
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<details className="detail-raw">
|
||||
<summary>Raw response</summary>
|
||||
<pre>{JSON.stringify(row, null, 2)}</pre>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activity && (
|
||||
<AssetActivityModal
|
||||
kind={activity}
|
||||
open
|
||||
instanceId={assetId}
|
||||
// On return or loss, the person who has it now is the likely one.
|
||||
defaultEmployeeId={
|
||||
(activity === 'return' || activity === 'lost') && isAssigned ? current.id : null
|
||||
}
|
||||
onClose={() => setActivity(null)}
|
||||
onDone={handleDone}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function DetailFields({ fields }) {
|
||||
return (
|
||||
<dl className="detail-fields">
|
||||
{fields.map((f) => (
|
||||
<div key={f.key ?? f.label} className="detail-field">
|
||||
<dt className="detail-field__label">{f.label}</dt>
|
||||
<dd className="detail-field__value">
|
||||
<FieldValue field={f} />
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
)
|
||||
}
|
||||
74
src/pages/assets/AssetList.jsx
Normal file
74
src/pages/assets/AssetList.jsx
Normal file
@ -0,0 +1,74 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Button, Card, Icon, PageHeader } from '../../components/ui'
|
||||
import RecordView from '../../components/activity/RecordView'
|
||||
import useRecordView from '../../hooks/useRecordView'
|
||||
import { RECORD_VIEWS } from '../../api/recordView'
|
||||
import { withAssetListDisplay } from '../../api/assets'
|
||||
import AddAssetModal from './AddAssetModal'
|
||||
|
||||
const VIEW = RECORD_VIEWS.assets
|
||||
|
||||
export default function AssetList() {
|
||||
const navigate = useNavigate()
|
||||
// Every field the Add Asset form fills gets a column, even if the view lacks
|
||||
// one, and Asset Owner shows the employee's name.
|
||||
const view = useRecordView(VIEW.templateUid, { transform: withAssetListDisplay })
|
||||
const [adding, setAdding] = useState(false)
|
||||
const [notice, setNotice] = useState(null)
|
||||
|
||||
/** `values` is keyed by Zino field id — see ASSET_FIELDS in api/assets.js. */
|
||||
function handleCreated(values) {
|
||||
setNotice({
|
||||
tone: 'success',
|
||||
text: `${values.field_42?.trim() || 'Asset'} was created and added to the inventory.`,
|
||||
})
|
||||
// Records live in the workflow, so re-read the view rather than guessing.
|
||||
view.reload()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Assets"
|
||||
subtitle="Every device in the inventory, read live from the asset workflow."
|
||||
actions={
|
||||
<Button variant="primary" icon="plus" onClick={() => setAdding(true)}>
|
||||
Add Asset
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{notice && (
|
||||
<div className={`notice notice--${notice.tone}`} role="status">
|
||||
<Icon name={notice.tone === 'success' ? 'check' : 'alert'} size={15} />
|
||||
<span>{notice.text}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="notice__close"
|
||||
onClick={() => setNotice(null)}
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<Icon name="close" size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card flush>
|
||||
<RecordView
|
||||
{...view}
|
||||
rowKey={VIEW.rowKey}
|
||||
emptyText="No assets yet."
|
||||
// Details show the asset's assignment and offer Assign when it's Available.
|
||||
onRowClick={(row) => navigate(`/assets/${encodeURIComponent(row[VIEW.rowKey])}`)}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<AddAssetModal
|
||||
open={adding}
|
||||
onClose={() => setAdding(false)}
|
||||
onCreated={handleCreated}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
130
src/pages/auth/Login.jsx
Normal file
130
src/pages/auth/Login.jsx
Normal file
@ -0,0 +1,130 @@
|
||||
import { useState } from 'react'
|
||||
import { Navigate, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { Button, Field, Icon, TextInput } from '../../components/ui'
|
||||
import { useAuth } from '../../store/AuthContext'
|
||||
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
|
||||
function validate({ email, password }) {
|
||||
const errors = {}
|
||||
if (!email.trim()) errors.email = 'Email is required.'
|
||||
else if (!EMAIL_RE.test(email.trim())) errors.email = 'Enter a valid email address.'
|
||||
if (!password) errors.password = 'Password is required.'
|
||||
return errors
|
||||
}
|
||||
|
||||
export default function Login() {
|
||||
const { isAuthenticated, login } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const location = useLocation()
|
||||
const redirectTo = location.state?.from?.pathname || '/'
|
||||
|
||||
const [form, setForm] = useState({ email: '', password: '' })
|
||||
const [errors, setErrors] = useState({})
|
||||
const [serverError, setServerError] = useState('')
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
if (isAuthenticated) return <Navigate to={redirectTo} replace />
|
||||
|
||||
const update = (key) => (e) => {
|
||||
const value = e.target.value
|
||||
setForm((f) => ({ ...f, [key]: value }))
|
||||
if (errors[key]) setErrors((er) => ({ ...er, [key]: undefined }))
|
||||
if (serverError) setServerError('')
|
||||
}
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault()
|
||||
const nextErrors = validate(form)
|
||||
setErrors(nextErrors)
|
||||
if (Object.keys(nextErrors).length) return
|
||||
|
||||
setSubmitting(true)
|
||||
setServerError('')
|
||||
try {
|
||||
await login({ email: form.email.trim(), password: form.password })
|
||||
navigate(redirectTo, { replace: true })
|
||||
} catch (err) {
|
||||
setServerError(err?.message || 'Something went wrong. Please try again.')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="auth">
|
||||
<div className="auth__panel">
|
||||
<div className="auth__brand">
|
||||
<span className="sidebar__logo">OA</span>
|
||||
<div>
|
||||
<div className="auth__brand-title">Office Assets</div>
|
||||
<div className="auth__brand-sub">Management</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 className="auth__title">Sign in</h1>
|
||||
<p className="auth__subtitle">Use your Zino account to continue.</p>
|
||||
|
||||
{serverError && (
|
||||
<div className="auth__alert" role="alert">
|
||||
<Icon name="alert" size={15} />
|
||||
<span>{serverError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form className="auth__form" onSubmit={handleSubmit} noValidate>
|
||||
<Field label="Email" required error={errors.email}>
|
||||
{(id) => (
|
||||
<TextInput
|
||||
id={id}
|
||||
type="email"
|
||||
name="email"
|
||||
autoComplete="username"
|
||||
placeholder="you@getzino.com"
|
||||
value={form.email}
|
||||
onChange={update('email')}
|
||||
disabled={submitting}
|
||||
autoFocus
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field label="Password" required error={errors.password}>
|
||||
{(id) => (
|
||||
<div className="auth__password">
|
||||
<TextInput
|
||||
id={id}
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
name="password"
|
||||
autoComplete="current-password"
|
||||
placeholder="••••••••"
|
||||
value={form.password}
|
||||
onChange={update('password')}
|
||||
disabled={submitting}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="auth__toggle"
|
||||
onClick={() => setShowPassword((s) => !s)}
|
||||
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPassword ? 'Hide' : 'Show'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Button type="submit" variant="primary" block disabled={submitting}>
|
||||
{submitting ? 'Signing in…' : 'Sign in'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<p className="auth__footer">
|
||||
Trouble signing in? Contact your IT administrator.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
107
src/pages/employees/AddEmployeeModal.jsx
Normal file
107
src/pages/employees/AddEmployeeModal.jsx
Normal file
@ -0,0 +1,107 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Button, Icon, Modal } from '../../components/ui'
|
||||
import ActivityFields from '../../components/activity/ActivityFields'
|
||||
import { EMPLOYEE_FIELDS, createEmployee } from '../../api/employees'
|
||||
import { emptyValues, validateActivity } from '../../api/activityForm'
|
||||
import { useAuth } from '../../store/AuthContext'
|
||||
|
||||
/**
|
||||
* "Add Employee" dialog. Fields are rendered straight from the Zino builder
|
||||
* definition in api/employees.js, so the form always matches the activity.
|
||||
*/
|
||||
export default function AddEmployeeModal({ open, onClose, onCreated }) {
|
||||
const navigate = useNavigate()
|
||||
const { logout } = useAuth()
|
||||
|
||||
const [values, setValues] = useState(() => emptyValues(EMPLOYEE_FIELDS))
|
||||
const [errors, setErrors] = useState({})
|
||||
const [serverError, setServerError] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
function reset() {
|
||||
setValues(emptyValues(EMPLOYEE_FIELDS))
|
||||
setErrors({})
|
||||
setServerError('')
|
||||
setSubmitting(false)
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (submitting) return
|
||||
reset()
|
||||
onClose?.()
|
||||
}
|
||||
|
||||
function handleChange(id, value) {
|
||||
setValues((v) => ({ ...v, [id]: value }))
|
||||
if (errors[id]) setErrors((er) => ({ ...er, [id]: undefined }))
|
||||
if (serverError) setServerError('')
|
||||
}
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault()
|
||||
const nextErrors = validateActivity(EMPLOYEE_FIELDS, values)
|
||||
setErrors(nextErrors)
|
||||
if (Object.keys(nextErrors).length) return
|
||||
|
||||
setSubmitting(true)
|
||||
setServerError('')
|
||||
try {
|
||||
const response = await createEmployee(values)
|
||||
onCreated?.(values, response)
|
||||
reset()
|
||||
onClose?.()
|
||||
} catch (err) {
|
||||
if (err?.status === 401) {
|
||||
logout()
|
||||
navigate('/login', { replace: true })
|
||||
return
|
||||
}
|
||||
setServerError(err?.message || 'Could not create the employee. Please try again.')
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={close}
|
||||
title="Add Employee"
|
||||
subtitle="Starts the employee onboarding workflow."
|
||||
size="lg"
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={close} disabled={submitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form="add-employee-form"
|
||||
variant="primary"
|
||||
icon={submitting ? undefined : 'plus'}
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? 'Creating…' : 'Create Employee'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{serverError && (
|
||||
<div className="auth__alert" role="alert">
|
||||
<Icon name="alert" size={15} />
|
||||
<span>{serverError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form id="add-employee-form" className="form__grid" onSubmit={handleSubmit} noValidate>
|
||||
<ActivityFields
|
||||
fields={EMPLOYEE_FIELDS}
|
||||
values={values}
|
||||
errors={errors}
|
||||
onChange={handleChange}
|
||||
disabled={submitting}
|
||||
/>
|
||||
</form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
309
src/pages/employees/EmployeeDetails.jsx
Normal file
309
src/pages/employees/EmployeeDetails.jsx
Normal file
@ -0,0 +1,309 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import {
|
||||
Avatar,
|
||||
BackLink,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
EmptyState,
|
||||
Icon,
|
||||
InfoList,
|
||||
InfoRow,
|
||||
PageHeader,
|
||||
} from '../../components/ui'
|
||||
import useDetailView from '../../hooks/useDetailView'
|
||||
import { DETAIL_VIEWS, detailValue } from '../../api/detailView'
|
||||
import { toISODate } from '../../api/recordView'
|
||||
import { instanceState, isEndState } from '../../api/activityForm'
|
||||
import FieldValue, { formatMeta } from '../../components/activity/FieldValue'
|
||||
import {
|
||||
ALL_EMPLOYEE_FIELDS,
|
||||
EMPLOYEE_UPDATE_FIELDS,
|
||||
withLatestInstanceValues,
|
||||
} from '../../api/employees'
|
||||
import { EMPLOYEE_STATUS_TONE } from '../../constants'
|
||||
import UpdateEmployeeModal from './UpdateEmployeeModal'
|
||||
import InactiveEmployeeModal from './InactiveEmployeeModal'
|
||||
|
||||
const VIEW = DETAIL_VIEWS.employees
|
||||
|
||||
/**
|
||||
* INIT ids first, Update ids last, so when a record holds both (field_2 and
|
||||
* field_10 for email) the value from the latest Update wins.
|
||||
*/
|
||||
const FIELD_DEFS = ALL_EMPLOYEE_FIELDS
|
||||
|
||||
/**
|
||||
* Employee details, read live from the workflow's detail view.
|
||||
* Route: /employees/:instanceId — the id of the employee's workflow instance.
|
||||
*/
|
||||
export default function EmployeeDetails() {
|
||||
const { instanceId } = useParams()
|
||||
const navigate = useNavigate()
|
||||
// The detail template can return old values after an Update, so merge in
|
||||
// the workflow instance, which always holds what was saved.
|
||||
const { view, loading, error, reload } = useDetailView(VIEW.templateUid, instanceId, {
|
||||
fieldDefs: FIELD_DEFS,
|
||||
enrich: withLatestInstanceValues,
|
||||
})
|
||||
const [updating, setUpdating] = useState(false)
|
||||
const [inactivating, setInactivating] = useState(false)
|
||||
const [notice, setNotice] = useState(null)
|
||||
|
||||
// Row for the Update dialog: values keyed by field label (what it pre-fills
|
||||
// from) plus the instance id, so it skips the lookup.
|
||||
const updateRow = useMemo(() => {
|
||||
if (!view) return null
|
||||
const row = {}
|
||||
for (const section of view.sections) {
|
||||
for (const f of section.fields) if (f.value != null) row[f.label] = f.value
|
||||
}
|
||||
for (const def of FIELD_DEFS) {
|
||||
const v = view.record[def.id]
|
||||
if (v !== undefined && v !== null && v !== '') row[def.label] = v
|
||||
}
|
||||
return { ...row, instance_id: instanceId }
|
||||
}, [view, instanceId])
|
||||
|
||||
const back = () => navigate('/employees')
|
||||
|
||||
if (loading && !view) {
|
||||
return (
|
||||
<>
|
||||
<BackLink onClick={back}>Back to Employees</BackLink>
|
||||
<Card>
|
||||
<EmptyState icon="⏳" title="Loading employee" text="Fetching from the workflow…" />
|
||||
</Card>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !view) {
|
||||
return (
|
||||
<>
|
||||
<BackLink onClick={back}>Back to Employees</BackLink>
|
||||
<Card>
|
||||
<EmptyState
|
||||
icon="🙁"
|
||||
title="Could not load this employee"
|
||||
text={error || 'The detail view returned nothing for this record.'}
|
||||
action={
|
||||
<div className="row">
|
||||
<Button onClick={reload}>Try again</Button>
|
||||
<Button variant="primary" onClick={back}>
|
||||
Back to Employees
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const firstName = detailValue(view, 'first_name', 'first name')
|
||||
const lastName = detailValue(view, 'last_name', 'last name')
|
||||
const name = [firstName, lastName].filter(Boolean).join(' ').trim() || 'Employee'
|
||||
const designation = detailValue(view, 'designation')
|
||||
const department = detailValue(view, 'department')
|
||||
const code = detailValue(view, 'employee_code', 'employee code', 'ID Generator')
|
||||
const status = detailValue(view, 'status')
|
||||
const email = detailValue(view, 'email')
|
||||
const phone = detailValue(view, 'phone')
|
||||
|
||||
// The instance carries current_state_name. Inactive moves it to End State,
|
||||
// which rejects every activity, so don't offer them there.
|
||||
const finished = isEndState(view.record)
|
||||
const subtitle = [designation, department, code].filter(Boolean).join(' · ')
|
||||
const empty = view.sections.every((s) => s.fields.length === 0)
|
||||
|
||||
/** `values` is keyed by the update field ids — see EMPLOYEE_UPDATE_FIELDS. */
|
||||
async function handleUpdated(values) {
|
||||
setNotice(null)
|
||||
const next = await reload()
|
||||
// Read the employee back and say plainly if any saved value didn't come back.
|
||||
const stale = next ? staleFields(next, values) : []
|
||||
setNotice(
|
||||
stale.length
|
||||
? {
|
||||
tone: 'warning',
|
||||
text:
|
||||
`Saved, but the workflow still returns the old value for ${stale.join(', ')}. ` +
|
||||
'The Zino views are not reading these Update fields yet.',
|
||||
}
|
||||
: { tone: 'success', text: 'Employee was updated.' },
|
||||
)
|
||||
}
|
||||
|
||||
async function handleInactivated() {
|
||||
setNotice({ tone: 'success', text: `${name} was marked inactive.` })
|
||||
await reload()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<BackLink onClick={back}>Back to Employees</BackLink>
|
||||
|
||||
<PageHeader
|
||||
title={name}
|
||||
subtitle={subtitle || undefined}
|
||||
actions={
|
||||
<>
|
||||
<Button icon="history" onClick={reload} disabled={loading}>
|
||||
{loading ? 'Refreshing…' : 'Refresh'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
icon="close"
|
||||
onClick={() => setInactivating(true)}
|
||||
disabled={finished}
|
||||
>
|
||||
Mark Inactive
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
icon="check"
|
||||
onClick={() => setUpdating(true)}
|
||||
disabled={finished}
|
||||
>
|
||||
Update Employee
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{finished && (
|
||||
<div className="notice notice--warning" role="status">
|
||||
<Icon name="alert" size={15} />
|
||||
<span>
|
||||
This employee has been marked inactive, so the workflow is in "
|
||||
{instanceState(view.record)}" and has finished. It can't be updated or marked inactive
|
||||
again.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{notice && (
|
||||
<div className={`notice notice--${notice.tone}`} role="status">
|
||||
<Icon name={notice.tone === 'success' ? 'check' : 'alert'} size={15} />
|
||||
<span>{notice.text}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="notice__close"
|
||||
onClick={() => setNotice(null)}
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<Icon name="close" size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="detail-grid">
|
||||
{/* ---------- Left: profile + record info ---------- */}
|
||||
<div className="stack">
|
||||
<Card flush>
|
||||
<div className="profile">
|
||||
<Avatar name={name} size="lg" />
|
||||
<div>
|
||||
<div className="profile__name">{name}</div>
|
||||
{designation && <div className="profile__role">{designation}</div>}
|
||||
{status && <Badge tone={statusTone(status)}>{String(status)}</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
<InfoList>
|
||||
<InfoRow label="Email">
|
||||
{email ? <a className="link" href={`mailto:${email}`}>{String(email)}</a> : null}
|
||||
</InfoRow>
|
||||
<InfoRow label="Phone">{phone != null ? String(phone) : null}</InfoRow>
|
||||
<InfoRow label="Employee code">{code != null ? String(code) : null}</InfoRow>
|
||||
</InfoList>
|
||||
</Card>
|
||||
|
||||
{view.meta.length > 0 && (
|
||||
<Card title="Record info" flush>
|
||||
<InfoList>
|
||||
{view.meta.map((m) => (
|
||||
<InfoRow key={m.key} label={m.label}>
|
||||
<span className="cell-mono">{formatMeta(m.value)}</span>
|
||||
</InfoRow>
|
||||
))}
|
||||
</InfoList>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ---------- Right: every field the detail view returns ---------- */}
|
||||
<div className="stack">
|
||||
{empty ? (
|
||||
<Card>
|
||||
<EmptyState
|
||||
icon="📭"
|
||||
title="No details"
|
||||
text="The detail view returned no fields for this employee."
|
||||
/>
|
||||
</Card>
|
||||
) : (
|
||||
view.sections.map((section, i) => (
|
||||
<Card key={section.title || i} title={section.title || 'Employee details'} flush>
|
||||
<dl className="detail-fields">
|
||||
{section.fields.map((f) => (
|
||||
<div key={f.key ?? f.label} className="detail-field">
|
||||
<dt className="detail-field__label">{f.label}</dt>
|
||||
<dd className="detail-field__value">
|
||||
<FieldValue field={f} />
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
|
||||
<details className="detail-raw">
|
||||
<summary>Raw response</summary>
|
||||
<pre>
|
||||
{JSON.stringify({ detail_view: view.raw, workflow_instance: view.instance ?? null }, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UpdateEmployeeModal
|
||||
open={updating}
|
||||
row={updateRow}
|
||||
onClose={() => setUpdating(false)}
|
||||
onUpdated={handleUpdated}
|
||||
/>
|
||||
|
||||
<InactiveEmployeeModal
|
||||
open={inactivating}
|
||||
instanceId={instanceId}
|
||||
onClose={() => setInactivating(false)}
|
||||
onDone={handleInactivated}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** Labels of the fields that were sent but don't read back with that value. */
|
||||
function staleFields(view, values) {
|
||||
const norm = (v, type) => {
|
||||
if (type === 'date') return toISODate(v)
|
||||
if (type === 'number') return String(v ?? '').replace(/[^\d.-]/g, '')
|
||||
return String(v ?? '').trim().toLowerCase()
|
||||
}
|
||||
return EMPLOYEE_UPDATE_FIELDS.filter((f) => {
|
||||
const sent = values?.[f.id]
|
||||
if (sent === undefined || sent === null || sent === '') return false
|
||||
return norm(detailValue(view, f.label), f.type) !== norm(sent, f.type)
|
||||
}).map((f) => f.label)
|
||||
}
|
||||
|
||||
/** Status badge tone, matched case-insensitively ("active" -> success). */
|
||||
function statusTone(status) {
|
||||
const key = Object.keys(EMPLOYEE_STATUS_TONE).find(
|
||||
(k) => k.toLowerCase() === String(status).trim().toLowerCase(),
|
||||
)
|
||||
return key ? EMPLOYEE_STATUS_TONE[key] : 'neutral'
|
||||
}
|
||||
130
src/pages/employees/EmployeeList.jsx
Normal file
130
src/pages/employees/EmployeeList.jsx
Normal file
@ -0,0 +1,130 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Button, Card, Icon, PageHeader, SelectInput } from '../../components/ui'
|
||||
import RecordView from '../../components/activity/RecordView'
|
||||
import useRecordView from '../../hooks/useRecordView'
|
||||
import { RECORD_VIEWS } from '../../api/recordView'
|
||||
import {
|
||||
EMPLOYMENT_FILTERS,
|
||||
fetchEmployeeListPage,
|
||||
resolveEmployeeInstanceId,
|
||||
} from '../../api/employees'
|
||||
import { useAuth } from '../../store/AuthContext'
|
||||
import AddEmployeeModal from './AddEmployeeModal'
|
||||
|
||||
const VIEW = RECORD_VIEWS.employees
|
||||
|
||||
export default function EmployeeList() {
|
||||
const navigate = useNavigate()
|
||||
const { logout } = useAuth()
|
||||
// The table misses Update fields Zino doesn't map and knows nothing of the
|
||||
// workflow state, so this fetcher overlays both and filters Active/Inactive.
|
||||
const view = useRecordView(VIEW.templateUid, { fetcher: fetchEmployeeListPage, filter: 'active' })
|
||||
const [adding, setAdding] = useState(false)
|
||||
const [opening, setOpening] = useState(false)
|
||||
const [notice, setNotice] = useState(null)
|
||||
|
||||
/** `values` is keyed by Zino field id — see EMPLOYEE_FIELDS in api/employees.js. */
|
||||
function handleCreated(values) {
|
||||
const name = [values.field_1, values.field_34].filter(Boolean).join(' ').trim()
|
||||
setNotice({
|
||||
tone: 'success',
|
||||
text: `${name || 'Employee'} was created and onboarding has started.`,
|
||||
})
|
||||
// Records live in the workflow, so re-read the view rather than guessing.
|
||||
view.reload()
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the detail view for a row. The detail view needs the workflow
|
||||
* instance_id: use the row's own when it has one, otherwise look it up.
|
||||
*/
|
||||
async function openDetails(row) {
|
||||
if (opening) return
|
||||
setOpening(true)
|
||||
setNotice(null)
|
||||
try {
|
||||
const instanceId = await resolveEmployeeInstanceId(row)
|
||||
if (instanceId == null) {
|
||||
setNotice({
|
||||
tone: 'danger',
|
||||
text: 'Could not find this employee in the workflow, so its details cannot be shown.',
|
||||
})
|
||||
return
|
||||
}
|
||||
navigate(`/employees/${encodeURIComponent(instanceId)}`)
|
||||
} catch (err) {
|
||||
if (err?.status === 401) {
|
||||
logout()
|
||||
navigate('/login', { replace: true })
|
||||
return
|
||||
}
|
||||
setNotice({ tone: 'danger', text: err?.message || 'Could not open this employee.' })
|
||||
} finally {
|
||||
setOpening(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Employees"
|
||||
subtitle="All staff on record, read live from the employee workflow."
|
||||
actions={
|
||||
<Button variant="primary" icon="plus" onClick={() => setAdding(true)}>
|
||||
Add Employee
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{opening && (
|
||||
<div className="notice notice--warning" role="status">
|
||||
<Icon name="search" size={15} />
|
||||
<span>Opening employee…</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{notice && (
|
||||
<div className={`notice notice--${notice.tone}`} role="status">
|
||||
<Icon name={notice.tone === 'success' ? 'check' : 'alert'} size={15} />
|
||||
<span>{notice.text}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="notice__close"
|
||||
onClick={() => setNotice(null)}
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<Icon name="close" size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card flush>
|
||||
<RecordView
|
||||
{...view}
|
||||
rowKey={VIEW.rowKey}
|
||||
emptyText={
|
||||
view.query.filter === 'inactive' ? 'No inactive employees.' : 'No employees yet.'
|
||||
}
|
||||
onRowClick={openDetails}
|
||||
toolbar={
|
||||
<div className="filter">
|
||||
<SelectInput
|
||||
value={view.query.filter}
|
||||
onChange={(e) => view.setFilter(e.target.value)}
|
||||
options={EMPLOYMENT_FILTERS}
|
||||
aria-label="Show employees"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<AddEmployeeModal
|
||||
open={adding}
|
||||
onClose={() => setAdding(false)}
|
||||
onCreated={handleCreated}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
114
src/pages/employees/InactiveEmployeeModal.jsx
Normal file
114
src/pages/employees/InactiveEmployeeModal.jsx
Normal file
@ -0,0 +1,114 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Button, Icon, Modal } from '../../components/ui'
|
||||
import ActivityFields from '../../components/activity/ActivityFields'
|
||||
import { EMPLOYEE_INACTIVE_FIELDS, inactivateEmployee } from '../../api/employees'
|
||||
import { emptyValues, validateActivity } from '../../api/activityForm'
|
||||
import { useAuth } from '../../store/AuthContext'
|
||||
|
||||
/**
|
||||
* "Mark Inactive" dialog. Runs the workflow's Inactive activity against one
|
||||
* existing employee. Fields come from EMPLOYEE_INACTIVE_FIELDS in
|
||||
* api/employees.js, so the form always matches the activity.
|
||||
*
|
||||
* Opened from the details page, which already knows the workflow instance id.
|
||||
*/
|
||||
export default function InactiveEmployeeModal({ open, instanceId, onClose, onDone }) {
|
||||
const navigate = useNavigate()
|
||||
const { logout } = useAuth()
|
||||
|
||||
const [values, setValues] = useState(() => emptyValues(EMPLOYEE_INACTIVE_FIELDS))
|
||||
const [errors, setErrors] = useState({})
|
||||
const [serverError, setServerError] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
// Start from a blank form each time the dialog opens.
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setValues(emptyValues(EMPLOYEE_INACTIVE_FIELDS))
|
||||
setErrors({})
|
||||
setServerError('')
|
||||
setSubmitting(false)
|
||||
}, [open])
|
||||
|
||||
function close() {
|
||||
if (submitting) return
|
||||
onClose?.()
|
||||
}
|
||||
|
||||
function handleChange(id, value) {
|
||||
setValues((v) => ({ ...v, [id]: value }))
|
||||
if (errors[id]) setErrors((er) => ({ ...er, [id]: undefined }))
|
||||
if (serverError) setServerError('')
|
||||
}
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault()
|
||||
if (instanceId == null || instanceId === '') {
|
||||
setServerError('This record has no instance id, so it cannot be marked inactive.')
|
||||
return
|
||||
}
|
||||
const nextErrors = validateActivity(EMPLOYEE_INACTIVE_FIELDS, values)
|
||||
setErrors(nextErrors)
|
||||
if (Object.keys(nextErrors).length) return
|
||||
|
||||
setSubmitting(true)
|
||||
setServerError('')
|
||||
try {
|
||||
const response = await inactivateEmployee(instanceId, values)
|
||||
onDone?.(values, response)
|
||||
onClose?.()
|
||||
} catch (err) {
|
||||
if (err?.status === 401) {
|
||||
logout()
|
||||
navigate('/login', { replace: true })
|
||||
return
|
||||
}
|
||||
setServerError(err?.message || 'Could not mark the employee inactive. Please try again.')
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={close}
|
||||
title="Mark Employee Inactive"
|
||||
subtitle="Runs the Inactive activity in the employee workflow."
|
||||
size="lg"
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={close} disabled={submitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form="inactive-employee-form"
|
||||
variant="danger"
|
||||
icon={submitting ? undefined : 'check'}
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? 'Saving…' : 'Mark Inactive'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{serverError && (
|
||||
<div className="auth__alert" role="alert">
|
||||
<Icon name="alert" size={15} />
|
||||
<span>{serverError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form id="inactive-employee-form" className="form__grid" onSubmit={handleSubmit} noValidate>
|
||||
<ActivityFields
|
||||
fields={EMPLOYEE_INACTIVE_FIELDS}
|
||||
values={values}
|
||||
errors={errors}
|
||||
onChange={handleChange}
|
||||
disabled={submitting}
|
||||
/>
|
||||
</form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
172
src/pages/employees/UpdateEmployeeModal.jsx
Normal file
172
src/pages/employees/UpdateEmployeeModal.jsx
Normal file
@ -0,0 +1,172 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Button, Icon, Modal } from '../../components/ui'
|
||||
import ActivityFields from '../../components/activity/ActivityFields'
|
||||
import {
|
||||
EMPLOYEE_UPDATE_FIELDS,
|
||||
resolveEmployeeInstanceId,
|
||||
updateEmployee,
|
||||
} from '../../api/employees'
|
||||
import { emptyValues, validateActivity } from '../../api/activityForm'
|
||||
import { toISODate } from '../../api/recordView'
|
||||
import { useAuth } from '../../store/AuthContext'
|
||||
|
||||
/**
|
||||
* "Update Employee" dialog. Runs the workflow's Update activity against one
|
||||
* existing record. Fields come from EMPLOYEE_UPDATE_FIELDS in api/employees.js,
|
||||
* so the form always matches the activity.
|
||||
*
|
||||
* `row` is a record-view row for the employee being updated. Its instance id is
|
||||
* sent as instance_id, and its current values pre-fill the form.
|
||||
*/
|
||||
export default function UpdateEmployeeModal({ open, row, onClose, onUpdated }) {
|
||||
const navigate = useNavigate()
|
||||
const { logout } = useAuth()
|
||||
|
||||
const [values, setValues] = useState(() => emptyValues(EMPLOYEE_UPDATE_FIELDS))
|
||||
const [errors, setErrors] = useState({})
|
||||
const [serverError, setServerError] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
// The list row carries only a table id, so resolve the real workflow
|
||||
// instance_id (needed by the Update activity) when the dialog opens.
|
||||
const [instanceId, setInstanceId] = useState(null)
|
||||
const [resolving, setResolving] = useState(false)
|
||||
|
||||
// Re-seed the form and resolve the instance id each time a row is opened.
|
||||
useEffect(() => {
|
||||
if (!open) return undefined
|
||||
setValues(initialFromRow(row))
|
||||
setErrors({})
|
||||
setServerError('')
|
||||
setSubmitting(false)
|
||||
setInstanceId(null)
|
||||
|
||||
let alive = true
|
||||
setResolving(true)
|
||||
resolveEmployeeInstanceId(row)
|
||||
.then((id) => {
|
||||
if (!alive) return
|
||||
setInstanceId(id)
|
||||
if (id == null) {
|
||||
setServerError(
|
||||
'Could not find this employee in the workflow, so it cannot be updated. ' +
|
||||
'It may not have an active workflow instance.',
|
||||
)
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!alive) return
|
||||
if (err?.status === 401) {
|
||||
logout()
|
||||
navigate('/login', { replace: true })
|
||||
return
|
||||
}
|
||||
setServerError(err?.message || 'Could not look up this employee.')
|
||||
})
|
||||
.finally(() => {
|
||||
if (alive) setResolving(false)
|
||||
})
|
||||
|
||||
return () => {
|
||||
alive = false
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, row])
|
||||
|
||||
function close() {
|
||||
if (submitting) return
|
||||
onClose?.()
|
||||
}
|
||||
|
||||
function handleChange(id, value) {
|
||||
setValues((v) => ({ ...v, [id]: value }))
|
||||
if (errors[id]) setErrors((er) => ({ ...er, [id]: undefined }))
|
||||
if (serverError) setServerError('')
|
||||
}
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault()
|
||||
if (!instanceId) {
|
||||
setServerError('This record has no instance id, so it cannot be updated.')
|
||||
return
|
||||
}
|
||||
const nextErrors = validateActivity(EMPLOYEE_UPDATE_FIELDS, values)
|
||||
setErrors(nextErrors)
|
||||
if (Object.keys(nextErrors).length) return
|
||||
|
||||
setSubmitting(true)
|
||||
setServerError('')
|
||||
try {
|
||||
const response = await updateEmployee(instanceId, values)
|
||||
onUpdated?.(values, response)
|
||||
onClose?.()
|
||||
} catch (err) {
|
||||
if (err?.status === 401) {
|
||||
logout()
|
||||
navigate('/login', { replace: true })
|
||||
return
|
||||
}
|
||||
setServerError(err?.message || 'Could not update the employee. Please try again.')
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={close}
|
||||
title="Update Employee"
|
||||
subtitle="Runs the Update activity in the employee workflow."
|
||||
size="lg"
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={close} disabled={submitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form="update-employee-form"
|
||||
variant="primary"
|
||||
icon={submitting ? undefined : 'check'}
|
||||
disabled={submitting || resolving || instanceId == null}
|
||||
>
|
||||
{submitting ? 'Updating…' : resolving ? 'Loading…' : 'Update Employee'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{serverError && (
|
||||
<div className="auth__alert" role="alert">
|
||||
<Icon name="alert" size={15} />
|
||||
<span>{serverError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form id="update-employee-form" className="form__grid" onSubmit={handleSubmit} noValidate>
|
||||
<ActivityFields
|
||||
fields={EMPLOYEE_UPDATE_FIELDS}
|
||||
values={values}
|
||||
errors={errors}
|
||||
onChange={handleChange}
|
||||
disabled={submitting}
|
||||
/>
|
||||
</form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort pre-fill: the record view returns each column under its own
|
||||
* field_key (usually the label, e.g. "first_name"), so match on the field's
|
||||
* label first, then its id. Anything not found stays blank.
|
||||
*/
|
||||
function initialFromRow(row) {
|
||||
const values = emptyValues(EMPLOYEE_UPDATE_FIELDS)
|
||||
if (!row) return values
|
||||
for (const field of EMPLOYEE_UPDATE_FIELDS) {
|
||||
const raw = row[field.label] ?? row[field.id]
|
||||
if (raw === undefined || raw === null || raw === '') continue
|
||||
values[field.id] = field.type === 'date' ? toISODate(raw) : String(raw)
|
||||
}
|
||||
return values
|
||||
}
|
||||
71
src/store/AppContext.jsx
Normal file
71
src/store/AppContext.jsx
Normal file
@ -0,0 +1,71 @@
|
||||
import { createContext, useContext, useMemo, useReducer } from 'react'
|
||||
import { initialData } from '../data/mockData'
|
||||
|
||||
/**
|
||||
* Single source of truth for the app. Local state only for now — swapping in a
|
||||
* real API later means replacing the dispatch handlers, not the components.
|
||||
*/
|
||||
|
||||
const AppStateContext = createContext(null)
|
||||
const AppDispatchContext = createContext(null)
|
||||
|
||||
export const ACTIONS = {
|
||||
ADD_EMPLOYEE: 'ADD_EMPLOYEE',
|
||||
UPDATE_EMPLOYEE: 'UPDATE_EMPLOYEE',
|
||||
ADD_ASSET: 'ADD_ASSET',
|
||||
UPDATE_ASSET: 'UPDATE_ASSET',
|
||||
}
|
||||
|
||||
function reducer(state, action) {
|
||||
switch (action.type) {
|
||||
case ACTIONS.ADD_EMPLOYEE:
|
||||
return { ...state, employees: [...state.employees, action.payload] }
|
||||
|
||||
case ACTIONS.UPDATE_EMPLOYEE:
|
||||
return {
|
||||
...state,
|
||||
employees: state.employees.map((e) =>
|
||||
e.id === action.payload.id ? { ...e, ...action.payload.changes } : e,
|
||||
),
|
||||
}
|
||||
|
||||
case ACTIONS.ADD_ASSET:
|
||||
return { ...state, assets: [...state.assets, action.payload] }
|
||||
|
||||
case ACTIONS.UPDATE_ASSET:
|
||||
return {
|
||||
...state,
|
||||
assets: state.assets.map((a) =>
|
||||
a.id === action.payload.id ? { ...a, ...action.payload.changes } : a,
|
||||
),
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown action: ${action.type}`)
|
||||
}
|
||||
}
|
||||
|
||||
export function AppProvider({ children }) {
|
||||
const [state, dispatch] = useReducer(reducer, initialData)
|
||||
const memoState = useMemo(() => state, [state])
|
||||
|
||||
return (
|
||||
<AppStateContext.Provider value={memoState}>
|
||||
<AppDispatchContext.Provider value={dispatch}>
|
||||
{children}
|
||||
</AppDispatchContext.Provider>
|
||||
</AppStateContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useAppState() {
|
||||
const ctx = useContext(AppStateContext)
|
||||
if (!ctx) throw new Error('useAppState must be used inside <AppProvider>')
|
||||
return ctx
|
||||
}
|
||||
|
||||
export function useAppDispatch() {
|
||||
const ctx = useContext(AppDispatchContext)
|
||||
if (!ctx) throw new Error('useAppDispatch must be used inside <AppProvider>')
|
||||
return ctx
|
||||
}
|
||||
92
src/store/AuthContext.jsx
Normal file
92
src/store/AuthContext.jsx
Normal file
@ -0,0 +1,92 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
|
||||
import { TOKEN_KEY, USER_KEY } from '../api/client'
|
||||
import { isTokenExpired, login as loginRequest } from '../api/auth'
|
||||
|
||||
/**
|
||||
* Session state: token + signed-in user, persisted in localStorage so a
|
||||
* refresh keeps you logged in until the JWT expires.
|
||||
*/
|
||||
|
||||
const AuthContext = createContext(null)
|
||||
|
||||
function readSession() {
|
||||
try {
|
||||
const token = localStorage.getItem(TOKEN_KEY)
|
||||
const rawUser = localStorage.getItem(USER_KEY)
|
||||
if (!token || isTokenExpired(token)) return { token: null, user: null }
|
||||
return { token, user: rawUser ? JSON.parse(rawUser) : null }
|
||||
} catch {
|
||||
return { token: null, user: null }
|
||||
}
|
||||
}
|
||||
|
||||
function writeSession({ token, user }) {
|
||||
try {
|
||||
if (token) {
|
||||
localStorage.setItem(TOKEN_KEY, token)
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(user ?? null))
|
||||
} else {
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
localStorage.removeItem(USER_KEY)
|
||||
}
|
||||
} catch {
|
||||
/* storage unavailable — session lives in memory only */
|
||||
}
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }) {
|
||||
const [session, setSession] = useState(readSession)
|
||||
|
||||
const login = useCallback(async ({ email, password }) => {
|
||||
const data = await loginRequest({ email, password })
|
||||
if (!data?.token) {
|
||||
throw new Error('Login succeeded but no token was returned.')
|
||||
}
|
||||
const next = { token: data.token, user: data.user ?? null }
|
||||
writeSession(next)
|
||||
setSession(next)
|
||||
return next.user
|
||||
}, [])
|
||||
|
||||
const logout = useCallback(() => {
|
||||
writeSession({ token: null, user: null })
|
||||
setSession({ token: null, user: null })
|
||||
}, [])
|
||||
|
||||
// Auto sign-out when the JWT expires while the tab is open.
|
||||
useEffect(() => {
|
||||
if (!session.token) return undefined
|
||||
const id = setInterval(() => {
|
||||
if (isTokenExpired(session.token)) logout()
|
||||
}, 30_000)
|
||||
return () => clearInterval(id)
|
||||
}, [session.token, logout])
|
||||
|
||||
// Keep multiple tabs in sync.
|
||||
useEffect(() => {
|
||||
const onStorage = (e) => {
|
||||
if (e.key === TOKEN_KEY || e.key === USER_KEY) setSession(readSession())
|
||||
}
|
||||
window.addEventListener('storage', onStorage)
|
||||
return () => window.removeEventListener('storage', onStorage)
|
||||
}, [])
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
token: session.token,
|
||||
user: session.user,
|
||||
isAuthenticated: Boolean(session.token),
|
||||
login,
|
||||
logout,
|
||||
}),
|
||||
[session, login, logout],
|
||||
)
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const ctx = useContext(AuthContext)
|
||||
if (!ctx) throw new Error('useAuth must be used inside <AuthProvider>')
|
||||
return ctx
|
||||
}
|
||||
47
src/store/selectors.js
Normal file
47
src/store/selectors.js
Normal file
@ -0,0 +1,47 @@
|
||||
import { ASSET_STATUS } from '../constants'
|
||||
|
||||
/** Pure lookups over the app state. Kept separate so they stay easy to reuse. */
|
||||
|
||||
export const getEmployeeById = (state, id) =>
|
||||
state.employees.find((e) => e.id === id) || null
|
||||
|
||||
export const getAssetById = (state, id) =>
|
||||
state.assets.find((a) => a.id === id) || null
|
||||
|
||||
export const getAssetByTag = (state, tag) =>
|
||||
state.assets.find((a) => a.assetTag === tag) || null
|
||||
|
||||
/** Assets currently held by an employee. */
|
||||
export const getAssetsByEmployee = (state, employeeId) =>
|
||||
state.assets.filter((a) => a.assignedTo === employeeId)
|
||||
|
||||
/** Assets that can be handed out right now. */
|
||||
export const getAvailableAssets = (state) =>
|
||||
state.assets.filter((a) => a.status === ASSET_STATUS.AVAILABLE)
|
||||
|
||||
/** Full timeline of one asset, oldest first. */
|
||||
export const getHistoryForAsset = (state, assetId) =>
|
||||
state.assetHistory
|
||||
.filter((h) => h.assetId === assetId)
|
||||
.slice()
|
||||
.sort(byDateThenId)
|
||||
|
||||
/** Every event that involved an employee, newest first. */
|
||||
export const getHistoryForEmployee = (state, employeeId) =>
|
||||
state.assetHistory
|
||||
.filter((h) => h.employeeId === employeeId)
|
||||
.slice()
|
||||
.sort((a, b) => byDateThenId(b, a))
|
||||
|
||||
/** The whole audit log, newest first. */
|
||||
export const getAllHistory = (state) =>
|
||||
state.assetHistory.slice().sort((a, b) => byDateThenId(b, a))
|
||||
|
||||
/** Employee name for display, tolerant of unknown/unassigned ids. */
|
||||
export const getEmployeeName = (state, employeeId) =>
|
||||
getEmployeeById(state, employeeId)?.name || null
|
||||
|
||||
function byDateThenId(a, b) {
|
||||
if (a.date !== b.date) return a.date < b.date ? -1 : 1
|
||||
return a.id < b.id ? -1 : 1
|
||||
}
|
||||
1657
src/styles/global.css
Normal file
1657
src/styles/global.css
Normal file
File diff suppressed because it is too large
Load Diff
49
src/utils/format.js
Normal file
49
src/utils/format.js
Normal file
@ -0,0 +1,49 @@
|
||||
const MONTHS = [
|
||||
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
||||
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
|
||||
]
|
||||
|
||||
/** '2026-09-15' -> '15-Sep-2026'. Returns a dash for empty values. */
|
||||
export function formatDate(iso) {
|
||||
if (!iso) return '—'
|
||||
const [y, m, d] = iso.split('-').map(Number)
|
||||
if (!y || !m || !d) return iso
|
||||
return `${String(d).padStart(2, '0')}-${MONTHS[m - 1]}-${y}`
|
||||
}
|
||||
|
||||
/** Today as an ISO date string, used as the default for date inputs. */
|
||||
export function todayISO() {
|
||||
const now = new Date()
|
||||
const pad = (n) => String(n).padStart(2, '0')
|
||||
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`
|
||||
}
|
||||
|
||||
/** Whole-rupee currency, e.g. 92000 -> '₹92,000'. */
|
||||
export function formatCurrency(value) {
|
||||
if (value == null) return '—'
|
||||
return `₹${value.toLocaleString('en-IN')}`
|
||||
}
|
||||
|
||||
/** Number of days between two ISO dates (b defaults to today). */
|
||||
export function daysBetween(isoA, isoB = todayISO()) {
|
||||
if (!isoA) return null
|
||||
const diff = new Date(isoB) - new Date(isoA)
|
||||
return Math.round(diff / 86400000)
|
||||
}
|
||||
|
||||
/** 'Rahul Kumar' -> 'RK' */
|
||||
export function initials(name = '') {
|
||||
return name
|
||||
.split(' ')
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((part) => part[0].toUpperCase())
|
||||
.join('')
|
||||
}
|
||||
|
||||
/** Case-insensitive "does any of these fields contain the term" helper. */
|
||||
export function matchesSearch(term, ...fields) {
|
||||
const q = term.trim().toLowerCase()
|
||||
if (!q) return true
|
||||
return fields.some((f) => String(f ?? '').toLowerCase().includes(q))
|
||||
}
|
||||
12
vite.config.js
Normal file
12
vite.config.js
Normal file
@ -0,0 +1,12 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig(({ command }) => ({
|
||||
// The preview runs at https://preview-dev.getzino.in/asset-management/
|
||||
base: command === "build" ? "/asset-management/" : "/",
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
open: true,
|
||||
},
|
||||
}));
|
||||
Loading…
Reference in New Issue
Block a user