Replace starter with office asset management app

This commit is contained in:
jayanth 2026-09-30 16:11:06 +05:30
parent d2d3a12e92
commit b67b6783cd
61 changed files with 10132 additions and 29 deletions

View File

@ -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
View File

@ -1,4 +1,8 @@
node_modules/
dist/
.env
node_modules
dist
dist-ssr
*.local
.vite
.DS_Store
npm-debug.log*
.env

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

View 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 &amp; 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 468 KiB

View File

@ -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>
</html>

1740
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
}

View 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>
)
}

View 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
}

View 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)
}

View 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 />
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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} />
}

View 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>
)
}

View 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>
)
}

View 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
View 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>
)
}

View 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>
)
}

View 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
View 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
View 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 }

View 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 }
}

View 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
View 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
View 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
View 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>
</>
)
}

View 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>
)
}

View 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>
</>
)
}

View 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>
)
}

View 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>
)
}

View 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
View 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>
)
}

View 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>
)
}

View 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'
}

View 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}
/>
</>
)
}

View 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>
)
}

View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

49
src/utils/format.js Normal file
View 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
View 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,
},
}));