Build app-434 field-sales console

API layer for sandbox app 434 (three workflows: Order Booking, Store,
Daily Reports) — ZinoClient, per-workflow client singletons, config ids,
types, and the source API docs.

Component library (Tailwind v4 + design tokens): buttons, reusable
(Card/Badge/Input/Select/Pagination/Modal/Spinner/EmptyState), generic +
wired record views (rv) and detail views (dv).

Screens + routing (react-router v7): login gate, auth-guarded console
shell with tab nav, and deep-linkable record/detail routes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Bhanu Prakash Sai Potteri 2026-07-06 15:16:34 +05:30
parent 3f36c92075
commit d5e4ebee5d
51 changed files with 3975 additions and 184 deletions

32
CLAUDE.md Normal file
View File

@ -0,0 +1,32 @@
# Krishna Sales
Vite + React + TypeScript frontend for **sandbox app 434** on the Zino platform.
- Base URL: `https://sandbox.getzino.in`
- Dev: `npm run dev` · Build: `npm run build` (`tsc -b && vite build`)
## Layout
- `src/api/client.ts``ZinoClient`, the HTTP client for the Zino gateway. Constructor is `new ZinoClient(baseUrl, workflowUuid, onAuthError?)`. Auth (login/logout/currentUser) + view/activity/upload calls. JWT persists in localStorage.
- `src/api/config.ts` — all app-434 ids: `APP_ID`, `BASE_URL`, and per-workflow uuids/activities/fields/views.
- `src/api/types.ts` — shared API response types.
- `src/docs/api/*.md` — the source API docs (one per workflow) these ids come from.
## Workflows (app 434)
The app has **three workflows**, each with its own `workflow_uuid`. `APP_ID` (434) is shared; every route is app-scoped (`/app/434/...`) except `/usr/login`. Instantiate one client per workflow: `new ZinoClient(BASE_URL, WORKFLOWS.<slug>.workflowUuid)`.
| Slug (config) | Name | Init activity | Other activities |
| --- | --- | --- | --- |
| `orderBooking` | Order Booking | Log Visit | Place Order, Productivity of Visit, Potential Mining, Close Order |
| `store` | Store | Init Activity | Edit Store |
| `dailyReports` | Daily Reports | Init Activity | Punch Out |
Field ids differ per activity even for the "same" field (e.g. Store's fields carry a `_2` suffix on init, `_3` on edit). Grid columns are noted in inline comments in `config.ts`. Always source ids from `config.ts`, never hardcode.
## Conventions
- Init activity → `POST /app/434/start`; subsequent activities → `POST /app/434/activity`. Both take `{workflow_uuid, activity_id, data, instance_id?}`.
- File/image fields: `POST /app/434/upload` (multipart) first, then send the returned `blob_path` as the field value.
- In-form option sources vary by field type: `wf-lookup/records`, `dataset-options`, `app-user/records` — see the per-workflow doc.
- When workflows/fields change, update `config.ts` from the docs in `src/docs/api/`.

125
README.md
View File

@ -1,75 +1,70 @@
# React + TypeScript + Vite # Krishna Sales
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. Field-sales console for **Zino sandbox app 434**. A Vite + React + TypeScript SPA that browses the app's three workflows (Order Booking, Store, Daily Reports) over the Zino gateway.
Currently, two official plugins are available: - **Base URL:** `https://sandbox.getzino.in`
- **Stack:** React 19 · TypeScript · Vite 8 · Tailwind v4 · react-router v7 · lucide-react
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) ## Getting started
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```bash
npm install
npm run dev # http://localhost:5173
``` ```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: Sign in with your sandbox credentials (email / password, optional org id). The JWT is stored in `localStorage` and shared across all three workflow clients.
```js Other scripts:
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([ ```bash
globalIgnores(['dist']), npm run build # tsc -b && vite build → dist/
{ npm run preview # serve the production build
files: ['**/*.{ts,tsx}'], npm run lint # eslint
extends: [ ```
// Other configs...
// Enable lint rules for React ## Workflows (app 434)
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM The app has three workflows, each with its own `workflow_uuid` (shared `APP_ID` 434). One `ZinoClient` is bound per workflow.
reactDom.configs.recommended,
], | Slug (`config.ts`) | UI | Record views | Detail views |
languageOptions: { | --- | --- | --- | --- |
parserOptions: { | `orderBooking` | Order Booking | Orders, Calls | Orders, Calls |
project: ['./tsconfig.node.json', './tsconfig.app.json'], | `store` | Store | Store | Store |
tsconfigRootDir: import.meta.dirname, | `dailyReports` | Daily Reports | Daily Logs | Daily Logs |
},
// other options... All ids live in `src/api/config.ts`, sourced from the API docs in `src/docs/api/`.
},
}, ## Structure
])
``` ```
src/
api/
client.ts ZinoClient — HTTP client (login, views, activities, upload)
clients.ts per-workflow client singletons + shared auth (loginAll…)
config.ts app 434 ids: APP_ID, workflow uuids, activities, fields, views
types.ts API response types
auth/ AuthProvider + useAuth (JWT session)
components/
buttons/ Button, IconButton
reusable/ Card, Badge, Input, Select, Pagination, Modal, Spinner, EmptyState
rv/ generic RecordView + wired Orders/Calls/Stores/DailyLogs views
dv/ generic DetailView + wired Order/Call/Store/DailyLog details
screens/ LoginPage, ConsoleLayout, WorkflowPage, tab defs
lib/ cn (classnames), format (value formatting)
styles/ Tailwind v4 theme + design tokens
docs/api/ source API docs (one per workflow)
```
## Routing
| Path | Screen |
| --- | --- |
| `/login` | Login gate |
| `/:tab` | Record view (`orders` · `calls` · `stores` · `daily`) |
| `/:tab/:instanceId` | Same list with the detail modal open (deep-linkable) |
Unknown paths redirect to `/orders`. Routes under the console require an authenticated session; a 401 bounces back to `/login`.
## Design system
Tailwind v4 with CSS-variable design tokens (`src/styles/`) — navy brand surface, sunrise accent gradient, slate neutrals. Utility names (`bg-navy-900`, `text-strong`, `shadow-md`, `rounded-lg`) resolve to the tokens via an `@theme` block. Referenced from the lead-to-policy console.

406
package-lock.json generated
View File

@ -8,11 +8,14 @@
"name": "frontend", "name": "frontend",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"lucide-react": "^0.400.0",
"react": "^19.2.7", "react": "^19.2.7",
"react-dom": "^19.2.7" "react-dom": "^19.2.7",
"react-router-dom": "^7.18.1"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@tailwindcss/vite": "^4.0.0",
"@types/node": "^24.13.2", "@types/node": "^24.13.2",
"@types/react": "^19.2.17", "@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
@ -21,6 +24,7 @@
"eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.3", "eslint-plugin-react-refresh": "^0.5.3",
"globals": "^17.7.0", "globals": "^17.7.0",
"tailwindcss": "^4.0.0",
"typescript": "~6.0.2", "typescript": "~6.0.2",
"typescript-eslint": "^8.62.0", "typescript-eslint": "^8.62.0",
"vite": "^8.1.1" "vite": "^8.1.1"
@ -837,6 +841,278 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@tailwindcss/node": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.2.tgz",
"integrity": "sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/remapping": "^2.3.5",
"enhanced-resolve": "5.21.6",
"jiti": "^2.7.0",
"lightningcss": "1.32.0",
"magic-string": "^0.30.21",
"source-map-js": "^1.2.1",
"tailwindcss": "4.3.2"
}
},
"node_modules/@tailwindcss/oxide": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.2.tgz",
"integrity": "sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 20"
},
"optionalDependencies": {
"@tailwindcss/oxide-android-arm64": "4.3.2",
"@tailwindcss/oxide-darwin-arm64": "4.3.2",
"@tailwindcss/oxide-darwin-x64": "4.3.2",
"@tailwindcss/oxide-freebsd-x64": "4.3.2",
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.2",
"@tailwindcss/oxide-linux-arm64-gnu": "4.3.2",
"@tailwindcss/oxide-linux-arm64-musl": "4.3.2",
"@tailwindcss/oxide-linux-x64-gnu": "4.3.2",
"@tailwindcss/oxide-linux-x64-musl": "4.3.2",
"@tailwindcss/oxide-wasm32-wasi": "4.3.2",
"@tailwindcss/oxide-win32-arm64-msvc": "4.3.2",
"@tailwindcss/oxide-win32-x64-msvc": "4.3.2"
}
},
"node_modules/@tailwindcss/oxide-android-arm64": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.2.tgz",
"integrity": "sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-darwin-arm64": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.2.tgz",
"integrity": "sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-darwin-x64": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.2.tgz",
"integrity": "sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-freebsd-x64": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.2.tgz",
"integrity": "sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.2.tgz",
"integrity": "sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.2.tgz",
"integrity": "sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.2.tgz",
"integrity": "sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.2.tgz",
"integrity": "sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.2.tgz",
"integrity": "sha512-cixpqbh2toJDmkuCRI68nXA8ZxNmdK9Y+9v5h3MC3ZQKy/0BO8AWzlkWyRM7JAFSGBlfig4YVTPsK6MVgqz1uw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.2.tgz",
"integrity": "sha512-4ec2Z/LOmRsAgU23CS4xeJfcJlmRg94A/XrbGRCF1gyU/zdDfRLYDVsS+ynSZCmGNxQ1jQriQOKMQeQxBA3Isw==",
"bundleDependencies": [
"@napi-rs/wasm-runtime",
"@emnapi/core",
"@emnapi/runtime",
"@tybys/wasm-util",
"@emnapi/wasi-threads",
"tslib"
],
"cpu": [
"wasm32"
],
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.11.1",
"@emnapi/runtime": "^1.11.1",
"@emnapi/wasi-threads": "^1.2.2",
"@napi-rs/wasm-runtime": "^1.1.4",
"@tybys/wasm-util": "^0.10.2",
"tslib": "^2.8.1"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.2.tgz",
"integrity": "sha512-Zyr/M0+XcYZu3bZrUytc7TXvrk0ftWfl8gN2MwekNDzhqhKRUucMPSeOzM0o0wH5AWOU49BsKRrfKxI2atCPMQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.2.tgz",
"integrity": "sha512-QI9BO7KlNZsp2GuO0jwAAj5jCDABOKXRkCk2XuKTSaNEFSdfzqswYVTtCHBNKHLsqyjFyFkqlDiwkNbTYSssMQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 20"
}
},
"node_modules/@tailwindcss/vite": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.2.tgz",
"integrity": "sha512-eHpMeX4JXfVNJDEcsouTeCBubJBTcTLigeaw/NTUW6PB5ATKKXdyonnXgTBX2VuRbjz1hjfz6C5XAhr52ImQXA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@tailwindcss/node": "4.3.2",
"@tailwindcss/oxide": "4.3.2",
"tailwindcss": "4.3.2"
},
"peerDependencies": {
"vite": "^5.2.0 || ^6 || ^7 || ^8"
}
},
"node_modules/@tybys/wasm-util": { "node_modules/@tybys/wasm-util": {
"version": "0.10.3", "version": "0.10.3",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
@ -1306,6 +1582,19 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/cross-spawn": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@ -1370,6 +1659,20 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/enhanced-resolve": {
"version": "5.21.6",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz",
"integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"graceful-fs": "^4.2.4",
"tapable": "^2.3.3"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/escalade": { "node_modules/escalade": {
"version": "3.2.0", "version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@ -1719,6 +2022,13 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"dev": true,
"license": "ISC"
},
"node_modules/hermes-estree": { "node_modules/hermes-estree": {
"version": "0.25.1", "version": "0.25.1",
"resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
@ -1786,6 +2096,16 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/jiti": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
"integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
"dev": true,
"license": "MIT",
"bin": {
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/js-tokens": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@ -2151,6 +2471,25 @@
"yallist": "^3.0.2" "yallist": "^3.0.2"
} }
}, },
"node_modules/lucide-react": {
"version": "0.400.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.400.0.tgz",
"integrity": "sha512-rpp7pFHh3Xd93KHixNgB0SqThMHpYNzsGUu69UaQbSZ75Q/J3m5t6EhKyMT3m4w2WOxmJ2mY0tD3vebnXqQryQ==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/minimatch": { "node_modules/minimatch": {
"version": "10.2.5", "version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
@ -2370,6 +2709,44 @@
"react": "^19.2.7" "react": "^19.2.7"
} }
}, },
"node_modules/react-router": {
"version": "7.18.1",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz",
"integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/react-router-dom": {
"version": "7.18.1",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.1.tgz",
"integrity": "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==",
"license": "MIT",
"dependencies": {
"react-router": "7.18.1"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
},
"node_modules/rolldown": { "node_modules/rolldown": {
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz",
@ -2420,6 +2797,12 @@
"semver": "bin/semver.js" "semver": "bin/semver.js"
} }
}, },
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/shebang-command": { "node_modules/shebang-command": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@ -2453,6 +2836,27 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/tailwindcss": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.2.tgz",
"integrity": "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==",
"dev": true,
"license": "MIT"
},
"node_modules/tapable": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz",
"integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/webpack"
}
},
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.17", "version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",

View File

@ -10,11 +10,14 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"lucide-react": "^0.400.0",
"react": "^19.2.7", "react": "^19.2.7",
"react-dom": "^19.2.7" "react-dom": "^19.2.7",
"react-router-dom": "^7.18.1"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@tailwindcss/vite": "^4.0.0",
"@types/node": "^24.13.2", "@types/node": "^24.13.2",
"@types/react": "^19.2.17", "@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
@ -23,6 +26,7 @@
"eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.3", "eslint-plugin-react-refresh": "^0.5.3",
"globals": "^17.7.0", "globals": "^17.7.0",
"tailwindcss": "^4.0.0",
"typescript": "~6.0.2", "typescript": "~6.0.2",
"typescript-eslint": "^8.62.0", "typescript-eslint": "^8.62.0",
"vite": "^8.1.1" "vite": "^8.1.1"

View File

@ -1,121 +1,23 @@
import { useState } from 'react' import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
import reactLogo from './assets/react.svg' import { AuthProvider } from './auth/AuthProvider'
import viteLogo from './assets/vite.svg' import { LoginPage } from './screens/LoginPage'
import heroImg from './assets/hero.png' import { ConsoleLayout } from './screens/ConsoleLayout'
import './App.css' import { WorkflowPage } from './screens/WorkflowPage'
function App() { function App() {
const [count, setCount] = useState(0)
return ( return (
<> <AuthProvider>
<section id="center"> <BrowserRouter>
<div className="hero"> <Routes>
<img src={heroImg} className="base" width="170" height="179" alt="" /> <Route path="/login" element={<LoginPage />} />
<img src={reactLogo} className="framework" alt="React logo" /> <Route element={<ConsoleLayout />}>
<img src={viteLogo} className="vite" alt="Vite logo" /> <Route path="/:tab" element={<WorkflowPage />} />
</div> <Route path="/:tab/:instanceId" element={<WorkflowPage />} />
<div> </Route>
<h1>Get started</h1> <Route path="*" element={<Navigate to="/orders" replace />} />
<p> </Routes>
Edit <code>src/App.tsx</code> and save to test <code>HMR</code> </BrowserRouter>
</p> </AuthProvider>
</div>
<button
type="button"
className="counter"
onClick={() => setCount((count) => count + 1)}
>
Count is {count}
</button>
</section>
<div className="ticks"></div>
<section id="next-steps">
<div id="docs">
<svg className="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#documentation-icon"></use>
</svg>
<h2>Documentation</h2>
<p>Your questions, answered</p>
<ul>
<li>
<a href="https://vite.dev/" target="_blank">
<img className="logo" src={viteLogo} alt="" />
Explore Vite
</a>
</li>
<li>
<a href="https://react.dev/" target="_blank">
<img className="button-icon" src={reactLogo} alt="" />
Learn more
</a>
</li>
</ul>
</div>
<div id="social">
<svg className="icon" role="presentation" aria-hidden="true">
<use href="/icons.svg#social-icon"></use>
</svg>
<h2>Connect with us</h2>
<p>Join the Vite community</p>
<ul>
<li>
<a href="https://github.com/vitejs/vite" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#github-icon"></use>
</svg>
GitHub
</a>
</li>
<li>
<a href="https://chat.vite.dev/" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#discord-icon"></use>
</svg>
Discord
</a>
</li>
<li>
<a href="https://x.com/vite_js" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#x-icon"></use>
</svg>
X.com
</a>
</li>
<li>
<a href="https://bsky.app/profile/vite.dev" target="_blank">
<svg
className="button-icon"
role="presentation"
aria-hidden="true"
>
<use href="/icons.svg#bluesky-icon"></use>
</svg>
Bluesky
</a>
</li>
</ul>
</div>
</section>
<div className="ticks"></div>
<section id="spacer"></section>
</>
) )
} }

287
src/api/client.ts Normal file
View File

@ -0,0 +1,287 @@
import type {
ActivityResult,
AiDecision,
ApiError,
AuditEntry,
FormScreenResponse,
LoginResponse,
RecordViewParams,
RecordViewResponse,
User,
} from './types';
import { APP_ID } from './config';
const TOKEN_KEY = 'krishna_sales_token';
/**
* HTTP client for the Zino gateway (sandbox).
*
* Routes are app-scoped (`/app/385/...`); login + ai-employee monitor are not.
* JWT persists in localStorage so the session survives refresh.
*/
export class ZinoClient {
readonly baseUrl: string;
readonly workflowUuid: string;
private token: string | null = null;
private onAuthError?: () => void;
constructor(baseUrl: string, workflowUuid: string, onAuthError?: () => void) {
this.baseUrl = baseUrl.replace(/\/+$/, '');
this.workflowUuid = workflowUuid;
this.onAuthError = onAuthError;
if (typeof window !== 'undefined') this.token = localStorage.getItem(TOKEN_KEY);
}
setAuthErrorHandler(fn: () => void): void {
this.onAuthError = fn;
}
setToken(token: string | null): void {
this.token = token;
if (typeof window === 'undefined') return;
if (token) localStorage.setItem(TOKEN_KEY, token);
else localStorage.removeItem(TOKEN_KEY);
}
getToken(): string | null {
return this.token;
}
private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (this.token) headers['Authorization'] = `Bearer ${this.token}`;
return this.send<T>(method, path, {
headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
});
}
// Multipart variant — no Content-Type header (the browser sets the boundary).
private async requestForm<T>(path: string, form: FormData): Promise<T> {
const headers: Record<string, string> = {};
if (this.token) headers['Authorization'] = `Bearer ${this.token}`;
return this.send<T>('POST', path, { headers, body: form });
}
private async send<T>(method: string, path: string, init: RequestInit): Promise<T> {
const res = await fetch(`${this.baseUrl}${path}`, { method, ...init });
if (res.status === 401) {
this.setToken(null);
this.onAuthError?.();
throw { status: 401, message: 'Unauthorized' } as ApiError;
}
if (!res.ok) {
let message = res.statusText;
try {
const j = (await res.json()) as { error?: string };
if (j.error) message = j.error;
} catch {
/* non-JSON body */
}
throw { status: res.status, message } as ApiError;
}
if (res.status === 204) return undefined as T;
return (await res.json()) as T;
}
// --- Auth ---
async login(email: string, password: string, orgId?: string): Promise<LoginResponse> {
const res = await this.request<LoginResponse>('POST', '/usr/login', {
email,
password,
...(orgId ? { org_id: Number(orgId) } : {}),
});
this.setToken(res.token);
return res;
}
logout(): void {
this.setToken(null);
}
/** Decode the persisted JWT into a User (no network). */
currentUser(): User | null {
if (!this.token) return null;
try {
const p = JSON.parse(atob(this.token.split('.')[1]));
return {
id: String(p.user_id ?? p.sub ?? ''),
org_id: String(p.org_id ?? ''),
name: p.name ?? '',
email: p.email ?? '',
roles: p.roles ?? [],
groups: p.groups ?? [],
};
} catch {
return null;
}
}
// --- Views ---
recordView(rvUid: string, params: RecordViewParams = {}): Promise<RecordViewResponse> {
return this.request<RecordViewResponse>('POST', `/app/${APP_ID}/view/recordview`, {
rv_template_uid: rvUid,
search_query: {
page: params.page ?? 1,
limit: params.limit ?? 50,
sort_by: params.sortBy ?? '',
sort_dir: params.sortDir ?? 'desc',
search: params.search ?? '',
filters: (params.filters ?? []).map((f) => ({
field_key: f.field_key,
value: f.value,
value2: '',
data_type: f.data_type ?? 'string',
})),
},
});
}
detailView(dvUid: string, instanceId: number | string): Promise<{
config: { fields: Array<{ field_key: string; output_label: string; data_type: string }> };
data: Record<string, unknown>;
}> {
return this.request(
'GET',
`/app/${APP_ID}/view/detailview/${dvUid}?instance_id=${encodeURIComponent(String(instanceId))}`,
);
}
audit(instanceId: number | string): Promise<AuditEntry[]> {
return this.request<AuditEntry[]>(
'GET',
`/app/${APP_ID}/view/audit?instance_id=${encodeURIComponent(String(instanceId))}`,
);
}
/** AI-employee decision stream for an instance (public monitor endpoint). */
aiDecisions(instanceId: number | string): Promise<AiDecision[]> {
return this.request<{ decisions: AiDecision[] }>(
'GET',
`/monitor/decisions?instance_id=${encodeURIComponent(String(instanceId))}`,
).then((r) => r.decisions ?? []);
}
// --- Form schema (view-service) ---
/** Live activity form config fields, types, options, lookup/ocr config and
* the designer layout. The single source of truth for rendering a form. */
formSchema(activityId: string, instanceId?: number | string): Promise<FormScreenResponse> {
return this.request<FormScreenResponse>('POST', `/app/${APP_ID}/view/form-screens`, {
activity_id: activityId,
device_type: 'desktop',
...(instanceId != null ? { instance_id: instanceId } : {}),
});
}
// --- Workflow execution (core) ---
startInstance(activityUid: string, data: Record<string, unknown>): Promise<ActivityResult> {
return this.request<ActivityResult>('POST', `/app/${APP_ID}/start`, {
workflow_uuid: this.workflowUuid,
activity_id: activityUid,
data,
});
}
performActivity(
instanceId: number | string,
activityUid: string,
data: Record<string, unknown>,
): Promise<ActivityResult> {
return this.request<ActivityResult>('POST', `/app/${APP_ID}/activity`, {
workflow_uuid: this.workflowUuid,
instance_id: typeof instanceId === 'string' ? Number(instanceId) || instanceId : instanceId,
activity_id: activityUid,
data,
});
}
// --- RDBMS lookup records (owner-agent picker etc.) ---
/** Search an RDBMS lookup template. Returns the matching rows. `formData`
* carries the current form values so the server can apply the activity's
* `field_rules` filter_options (dependent/cascading lookups, e.g. owner
* agents scoped to the chosen region). */
lookupRecords(
templateUid: string,
opts: {
activityId: string;
fieldId: string;
search?: string;
instanceId?: number | string;
limit?: number;
formData?: Record<string, unknown>;
},
): Promise<{ records: Array<Record<string, unknown>> }> {
return this.request('POST', `/app/${APP_ID}/rdbms-templates/${templateUid}/records`, {
workflow_uuid: this.workflowUuid,
activity_id: opts.activityId,
field_id: opts.fieldId,
instance_id: opts.instanceId || undefined,
search: opts.search ?? '',
limit: opts.limit ?? 50,
offset: 0,
form_data: opts.formData ?? {},
});
}
// --- Dataset-backed select options (state/city cascade etc.) ---
/** Fetch options for a dataset-backed select. The server resolves the field's
* dataset + filter_options from `activityId`/`fieldId`, applying the activity's
* rules against `formData` so e.g. City options come back already scoped to
* the chosen State. Returns `{label, value}` rows (plus the raw dataset row). */
datasetOptions(opts: {
activityId: string;
fieldId: string;
search?: string;
instanceId?: number | string;
limit?: number;
formData?: Record<string, unknown>;
}): Promise<{ options: Array<{ label: string; value: string; _raw?: Record<string, string> }> }> {
return this.request('POST', `/app/${APP_ID}/dataset-options`, {
workflow_uuid: this.workflowUuid,
activity_id: opts.activityId,
field_id: opts.fieldId,
instance_id: opts.instanceId || undefined,
search: opts.search ?? '',
limit: opts.limit ?? 200,
offset: 0,
form_data: opts.formData ?? {},
});
}
// --- File upload + OCR (multipart, field-scoped) ---
private fieldForm(file: File, ctx: FieldContext): FormData {
const form = new FormData();
form.append('file', file);
form.append('workflow_uuid', this.workflowUuid);
form.append('activity_id', ctx.activityId);
form.append('field_id', ctx.fieldId);
if (ctx.instanceId != null) form.append('instance_id', String(ctx.instanceId));
return form;
}
/** Upload a form-field file; returns the FileMeta to store as the field value. */
uploadFile(file: File, ctx: FieldContext): Promise<Record<string, unknown>> {
return this.requestForm(`/app/${APP_ID}/upload`, this.fieldForm(file, ctx));
}
/** Run OCR on a document; `extracted` is keyed by the field's extraction keys. */
extractOcr(file: File, ctx: FieldContext): Promise<{ extracted: Record<string, unknown>; raw?: string; parse_error?: string }> {
return this.requestForm(`/app/${APP_ID}/ocr-extract`, this.fieldForm(file, ctx));
}
}
/** Identifiers the field-scoped CORE endpoints resolve config + RBAC from. */
export interface FieldContext {
activityId: string;
fieldId: string;
instanceId?: number | string;
}

49
src/api/clients.ts Normal file
View File

@ -0,0 +1,49 @@
// Per-workflow ZinoClient singletons. app 434 has three workflows, each with
// its own workflow_uuid, so each needs its own client (ZinoClient binds one
// workflow uuid at construction). Import the one you need, or use clientFor().
import { ZinoClient } from './client';
import { BASE_URL, WORKFLOWS } from './config';
export type WorkflowSlug = keyof typeof WORKFLOWS;
const cache = new Map<WorkflowSlug, ZinoClient>();
/** Shared client for a workflow (memoized so the JWT/session is reused). */
export function clientFor(slug: WorkflowSlug): ZinoClient {
let c = cache.get(slug);
if (!c) {
c = new ZinoClient(BASE_URL, WORKFLOWS[slug].workflowUuid);
cache.set(slug, c);
}
return c;
}
export const orderBookingClient = clientFor('orderBooking');
export const storeClient = clientFor('store');
export const dailyReportsClient = clientFor('dailyReports');
// The user JWT (from /usr/login) is valid across every workflow, but each
// client holds its own copy — so auth helpers fan out to all of them.
const ALL = [orderBookingClient, storeClient, dailyReportsClient];
/** Log in once and share the JWT with every workflow client. */
export async function loginAll(email: string, password: string, orgId?: string) {
const res = await orderBookingClient.login(email, password, orgId);
ALL.forEach((c) => c.setToken(res.token));
return res;
}
/** Clear the session on every client. */
export function logoutAll(): void {
ALL.forEach((c) => c.setToken(null));
}
/** Current shared token (null when logged out). */
export function currentToken(): string | null {
return orderBookingClient.getToken();
}
/** Register a 401 handler on every client (e.g. to bounce back to login). */
export function onAuthErrorAll(fn: () => void): void {
ALL.forEach((c) => c.setAuthErrorHandler(fn));
}

167
src/api/config.ts Normal file
View File

@ -0,0 +1,167 @@
// Krishna Sales — sandbox app 434 (base https://sandbox.getzino.in).
// Three workflows share the app: Order Booking, Store, Daily Reports. Each has
// its own workflow_uuid + version_uuid; instantiate one ZinoClient per workflow
// (new ZinoClient(baseUrl, WORKFLOWS.<x>.workflowUuid)). All ids from the API
// docs under src/docs/api.
export const APP_ID = '434';
export const BASE_URL = 'https://sandbox.getzino.in';
// --- Order Booking (src/docs/api/order_booking.md) ---
export const ORDER_BOOKING = {
workflowUuid: 'b5a21627-9422-40cf-b7f5-31fa070bf42f',
versionUuid: 'f19b2146-88e5-448a-9a51-e517ebce680a',
activities: {
LOG_VISIT: {
uid: '42b7f47d-96f0-4289-a6d9-38f455ad57dd', // init
fields: {
selectStore: 'select_store', // wf_lookup
dateOfVisit: 'date_of_visit', // date
timeOfVisit: 'time_of_visit', // time
uploadImage: 'upload_image', // image
},
},
PLACE_ORDER: {
uid: 'f66403d7-e31f-4d64-bcfd-ff5485aec8b8',
fields: {
dateOfOrder: 'date_of_order', // date
orderDetails: 'order_details', // grid: br_code, sku_code, product_category, product_name, sku, bags
totalBags: 'total_bags', // number
totalKgs: 'total_kgs', // number
},
},
PRODUCTIVITY_OF_VISIT: {
uid: '11bd10f9-a001-470e-867f-33dee8eabe4b',
fields: {
isProductiveCall: 'is_productive_call', // radio
storeStatus: 'store_status', // select
orderReceivedChannel: 'order_received_channel', // select
remarks: 'remarks', // longtext
action: 'action', // radio
},
},
POTENTIAL_MINING: {
uid: 'af8ac8df-d868-4f7d-88b2-123955d69c56',
fields: {
potential: 'potential', // grid: product_category_, total_ordered, actual_potential, difference, reason, potential_remarks
},
},
CLOSE_ORDER: {
uid: '8580d7a2-5d31-40ab-b7ea-2a38d144e59a',
fields: {
remarks: 'remarks_2', // longtext
},
},
},
recordViews: {
ORDERS: '1e424561-9a27-44f5-9b68-64d63296d837',
CALLS: 'b4d7a710-24e7-416d-885a-31fd1e727aab',
},
detailViews: {
ORDERS: '0804c6c3-6cf9-4050-94bb-fa48dd5de87d',
CALLS: '09a32bfe-9e43-4e0f-bb7d-db89c8a2557c',
},
} as const;
// --- Store (src/docs/api/store.md) ---
export const STORE = {
workflowUuid: '2f827d27-b7a4-4ed9-9336-879fa46dbcba',
versionUuid: 'ced1fb5b-c6a4-4305-b85d-3c263d9d618e',
activities: {
// Init (fields carry the _2 suffix).
INIT: {
uid: '8626a77f-9e70-448e-8ac9-2c51eae5015b', // init
fields: {
businessName: 'business_name_2', // text
area: 'area_2', // text
completeAddress: 'complete_address_2', // longtext
pinCode: 'pin_code_2', // number
ownerName: 'owner_name_2', // text
phoneNumber: 'phone_number_2', // phone
email: 'email_2', // email
storeLocation: 'store_location_2', // geolocation
storeImage: 'store_image_2', // image
routeName: 'route_name_2', // select
routeCode: 'route_code_2', // select
subRoute: 'sub_route_2', // select
distributorName: 'distributor_name_2', // select
distributorOwnerName: 'distributor_owner_name_2', // text
distributorEmail: 'distributor_email_2', // email
distributorPhoneNumber: 'distributor_phone_number_2', // phone
notes: 'notes_2', // longtext
potential: 'potential_2', // grid: product_category_1, quantity_1
},
},
// Edit (same fields, _3 suffix).
EDIT_STORE: {
uid: '87647fae-3b07-445f-9615-21881b163276',
fields: {
businessName: 'business_name_3', // text
area: 'area_3', // text
completeAddress: 'complete_address_3', // longtext
pinCode: 'pin_code_3', // number
ownerName: 'owner_name_3', // text
phoneNumber: 'phone_number_3', // phone
email: 'email_3', // email
storeLocation: 'store_location_3', // geolocation
storeImage: 'store_image_3', // image
routeName: 'route_name_3', // select
routeCode: 'route_code_3', // select
subRoute: 'sub_route_3', // select
distributorName: 'distributor_name_3', // select
distributorOwnerName: 'distributor_owner_name_3', // text
distributorEmail: 'distributor_email_3', // email
distributorPhoneNumber: 'distributor_phone_number_3', // phone
notes: 'notes_3', // longtext
potential: 'potential_3', // grid: col_1, col_2
},
},
},
recordViews: {
STORE: 'c03b411a-8454-4d8b-8220-00095e92a5de',
},
detailViews: {
STORE: 'af868269-d340-4ea9-9793-5fabe4f6ae27',
},
} as const;
// --- Daily Reports (src/docs/api/daily_reports.md) ---
export const DAILY_REPORTS = {
workflowUuid: 'c420dc04-2ba3-488e-902d-66bc9a2032cd',
versionUuid: 'd4d4c738-bf74-4b44-a297-9a6653458f3a',
activities: {
INIT: {
uid: 'ac04a444-9b96-463d-bc91-447fa80b028f', // init
fields: {
salesOfficerName: 'sales_officer_name', // app_user
date: 'date', // date
time: 'field_1', // time
routeCode: 'route_code', // select
subRoute: 'sub_route', // select
storeImage: 'store_image', // image
dayPlanNotes: 'day_plan_notes', // longtext
},
},
PUNCH_OUT: {
uid: '37c218fc-fd91-41d1-9964-fcaeade54e74',
fields: {
totalProductiveCalls: 'total_productive_calls', // number
totalNonProductiveCalls: 'total_non_productive_calls', // number
eodNotesRemarks: 'eod_notes_remarks', // longtext
},
},
},
recordViews: {
DAILY_LOGS: '269da629-1701-477a-a37b-a78bb056d4b3',
},
detailViews: {
DAILY_LOGS: '26b3c6de-a1d7-4f71-be88-7e4929932b0b',
},
} as const;
// All workflows keyed by slug, for generic lookup.
export const WORKFLOWS = {
orderBooking: ORDER_BOOKING,
store: STORE,
dailyReports: DAILY_REPORTS,
} as const;

191
src/api/types.ts Normal file
View File

@ -0,0 +1,191 @@
// Live API shapes + the SDK's domain types.
export interface ApiError {
status: number;
message: string;
}
export interface User {
id: string;
org_id: string;
name: string;
email: string;
roles: string[];
groups: string[];
}
export interface LoginResponse {
token: string;
user: User;
}
// --- Record view (POST /app/{appId}/view/recordview) ---
export interface RecordViewField {
field_key: string;
output_label: string;
data_type: string;
is_filter: boolean;
is_search: boolean;
}
export interface RecordViewResponse {
config: { fields: RecordViewField[] };
data: Record<string, unknown>[];
pagination?: { page: number; limit: number; total_count: number; total_pages: number };
}
export interface RecordViewParams {
page?: number;
limit?: number;
sortBy?: string;
sortDir?: 'asc' | 'desc';
search?: string;
filters?: Array<{ field_key: string; value: string; data_type?: string }>;
}
// --- Form schema (POST /app/{appId}/view/form-screens) ---
// The live activity form config. `fields` is the metadata pool; `grid_config`
// is the designer's layout and decides which fields are user-facing.
export interface FormScreenFieldProps {
options?: Array<{ label: string; value: string }>;
/** Dataset-backed select/multiselect: options come live from
* /dataset-options (filter_options-aware), not the static `options` above.
* `display_keys` are joined for the label; `value_key` is the stored value. */
option_source?: string; // 'dataset' | (else inline)
dataset_uuid?: string;
display_keys?: string[];
value_key?: string;
lookup_config?: {
rdbms_template_uuid: string;
display_columns?: Array<{ name: string }>;
storage_columns?: Array<{ name: string }>;
};
ocr_config?: {
template?: string;
field_mappings?: Array<{ target_field: string; extraction_key: string }>;
};
source?: string;
disabled?: boolean;
}
export interface FormScreenField {
id: string;
uid: string;
name: string;
data_type: string;
mandatory?: boolean;
/** Companion/record column this field writes — the canonical seed key. */
mapped_workflow_field?: string;
type?: string; // 'mapped' | 'local'
properties?: FormScreenFieldProps | null;
}
export interface FormScreenGridItem {
type: string; // 'field' | ...
fieldUid: string;
x: number;
y: number;
}
/** One condition of a field_rule filter_config. The lookup row's
* `target_column` is compared (currently only `equals`) against the current
* value of `form_field`. When the source field is itself a lookup/object,
* `source_value_key` names the sub-key to read off it (e.g. a region lookup's
* "region" column); empty means use the form value directly. */
export interface FieldRuleCondition {
operator: string;
form_field: string;
target_column: string;
source_value_key?: string;
}
/** A designer field_rule. We honour `action: "filter_options"`, which scopes a
* target field's options/lookup rows by another field's current value. */
export interface FieldRule {
id: string;
action: string;
active?: boolean;
target_field: string;
filter_config?: {
logic?: string; // "AND" | "OR"
conditions?: FieldRuleCondition[];
} | null;
}
/** Per-field designer defaults, keyed by field id. `hidden` drops the field
* from the form UI while its mapped value is still seeded + submitted. */
export interface FieldDefault {
hidden?: boolean;
/** Read-only in the form — value is computed (e.g. premium_amount by custom_js). */
disabled?: boolean;
value?: unknown;
}
export interface FormScreenResponse {
activity_uid: string;
activity_name: string;
fields: FormScreenField[];
grid_config?: FormScreenGridItem[];
/** Designer field_rules (cascading filter_options etc.). */
field_rules?: FieldRule[];
/** Per-field designer defaults (e.g. `{ assign_city: { hidden: true } }`). */
field_defaults?: Record<string, FieldDefault>;
}
// --- Audit (GET /app/{appId}/view/audit) ---
export interface AuditEntry {
id: number;
user_id: string;
user_roles: string[];
activity_id: string;
data: Record<string, unknown>;
execution_state?: string;
created_at: string;
}
// --- AI decisions (GET /monitor/decisions) ---
export interface AiDecision {
id: number;
ai_user_id: string;
instance_id: string;
activity_id: string;
activity_name?: string;
/** Server-resolved display name for the AI employee (preferred over the
* static AI_EMPLOYEES map). */
employee_name?: string;
reasoning: string;
confidence: number;
status: string;
instance_state?: string;
knowledge_sources?: unknown;
tool_calls?: unknown;
/** The activity payload the AI employee submitted e.g. the Aria Advisor
* product recommendation carries ai_recommended_product_input /
* ai_suggested_sum_assured_input / ai_recommendation_confidence_input here. */
data?: Record<string, unknown>;
created_at: string;
}
// --- Workflow execution (core) ---
export interface ActivityResult {
success?: boolean;
instance_id?: number | string;
data?: Record<string, unknown>;
message?: string;
status_code?: number;
error?: string;
}
// Several recordview fields come back as structured objects, not scalars.
// Normalize via the helpers in adapters.ts before rendering/seeding.
export interface PhoneValue {
dial_code?: string;
phone?: string;
phone_with_dial_code?: string;
}

21
src/auth/AuthProvider.tsx Normal file
View File

@ -0,0 +1,21 @@
import { useState, type ReactNode } from 'react';
import { loginAll, logoutAll, currentToken } from '../api/clients';
import { AuthCtx, type AuthValue } from './context';
export function AuthProvider({ children }: { children: ReactNode }) {
const [authed, setAuthed] = useState(() => !!currentToken());
const value: AuthValue = {
authed,
login: async (email, password, orgId) => {
await loginAll(email, password, orgId);
setAuthed(true);
},
logout: () => {
logoutAll();
setAuthed(false);
},
};
return <AuthCtx.Provider value={value}>{children}</AuthCtx.Provider>;
}

15
src/auth/context.ts Normal file
View File

@ -0,0 +1,15 @@
import { createContext, useContext } from 'react';
export interface AuthValue {
authed: boolean;
login: (email: string, password: string, orgId?: string) => Promise<void>;
logout: () => void;
}
export const AuthCtx = createContext<AuthValue | null>(null);
export function useAuth(): AuthValue {
const v = useContext(AuthCtx);
if (!v) throw new Error('useAuth must be used within <AuthProvider>');
return v;
}

View File

@ -0,0 +1,65 @@
import type { ButtonHTMLAttributes, ReactNode } from 'react';
import { cn } from '../../lib/cn';
export type ButtonVariant = 'primary' | 'navy' | 'secondary' | 'ghost' | 'danger';
export type ButtonSize = 'sm' | 'md' | 'lg';
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
/** Visual style. @default "primary" */
variant?: ButtonVariant;
/** @default "md" */
size?: ButtonSize;
iconLeft?: ReactNode;
iconRight?: ReactNode;
/** Stretch to container width. @default false */
full?: boolean;
}
const SIZES: Record<ButtonSize, string> = {
sm: 'h-[34px] px-3.5 text-[13px] rounded-sm',
md: 'h-[42px] px-[18px] text-base rounded-md',
lg: 'h-[50px] px-6 text-md rounded-md',
};
const VARIANTS: Record<ButtonVariant, string> = {
primary: 'bg-sunrise text-white border border-transparent shadow-sunrise',
navy: 'bg-navy-900 text-on-navy border border-transparent shadow-sm',
secondary: 'bg-card text-strong border border-border-default shadow-xs',
ghost: 'bg-transparent text-body border border-transparent',
danger: 'bg-ruby-600 text-white border border-transparent shadow-sm',
};
/**
* Primary action button. Sunrise gradient for primary, solid navy for
* secondary emphasis, quiet ghost for tertiary.
*/
export function Button({
children,
variant = 'primary',
size = 'md',
iconLeft,
iconRight,
full = false,
className,
...rest
}: ButtonProps) {
return (
<button
className={cn(
'inline-flex items-center justify-center gap-2 font-sans font-semibold leading-none',
'cursor-pointer transition-[transform,filter,box-shadow] duration-150',
'hover:brightness-[0.98] active:scale-[0.97]',
'disabled:opacity-50 disabled:cursor-not-allowed disabled:active:scale-100',
SIZES[size],
VARIANTS[variant],
full ? 'w-full' : 'w-auto',
className,
)}
{...rest}
>
{iconLeft}
{children}
{iconRight}
</button>
);
}

View File

@ -0,0 +1,36 @@
import type { ButtonHTMLAttributes, ReactNode } from 'react';
import { cn } from '../../lib/cn';
export interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
/** The icon (e.g. a lucide-react element). */
icon: ReactNode;
/** Accessible label — required since there's no visible text. */
label: string;
/** @default "md" */
size?: 'sm' | 'md';
}
const SIZES = {
sm: 'h-8 w-8',
md: 'h-[42px] w-[42px]',
} as const;
/** Square icon-only button. Quiet by default; use for row/toolbar actions. */
export function IconButton({ icon, label, size = 'md', className, ...rest }: IconButtonProps) {
return (
<button
aria-label={label}
title={label}
className={cn(
'inline-flex items-center justify-center rounded-md border border-border-default bg-card text-muted shadow-xs',
'cursor-pointer transition-colors duration-150 hover:text-strong hover:bg-slate-50',
'disabled:opacity-50 disabled:cursor-not-allowed',
SIZES[size],
className,
)}
{...rest}
>
{icon}
</button>
);
}

View File

@ -0,0 +1,4 @@
export { Button } from './Button';
export type { ButtonProps, ButtonVariant, ButtonSize } from './Button';
export { IconButton } from './IconButton';
export type { IconButtonProps } from './IconButton';

View File

@ -0,0 +1,17 @@
import { orderBookingClient } from '../../api/clients';
import { ORDER_BOOKING } from '../../api/config';
import { DetailView } from './DetailView';
import type { WiredDetailViewProps } from './OrderDetail';
/** Call detail view (Order Booking workflow). */
export function CallDetail({ instanceId, columns }: WiredDetailViewProps) {
return (
<DetailView
client={orderBookingClient}
dvUid={ORDER_BOOKING.detailViews.CALLS}
instanceId={instanceId}
title="Call"
columns={columns}
/>
);
}

View File

@ -0,0 +1,17 @@
import { dailyReportsClient } from '../../api/clients';
import { DAILY_REPORTS } from '../../api/config';
import { DetailView } from './DetailView';
import type { WiredDetailViewProps } from './OrderDetail';
/** Daily Log detail view (Daily Reports workflow). */
export function DailyLogDetail({ instanceId, columns }: WiredDetailViewProps) {
return (
<DetailView
client={dailyReportsClient}
dvUid={DAILY_REPORTS.detailViews.DAILY_LOGS}
instanceId={instanceId}
title="Daily Log"
columns={columns}
/>
);
}

View File

@ -0,0 +1,97 @@
import { useEffect, useState } from 'react';
import { cn } from '../../lib/cn';
import { formatValue } from '../../lib/format';
import type { ZinoClient } from '../../api/client';
import { Card } from '../reusable/Card';
import { Spinner } from '../reusable/Spinner';
import { EmptyState } from '../reusable/EmptyState';
interface DetailField {
field_key: string;
output_label: string;
data_type: string;
}
interface DetailResponse {
config: { fields: DetailField[] };
data: Record<string, unknown>;
}
export interface DetailViewProps {
/** Workflow-bound client (see api/clients.ts). */
client: ZinoClient;
/** detailview template uid. */
dvUid: string;
/** Instance to render. */
instanceId: number | string;
/** Card header title. */
title?: string;
/** Restrict/order visible fields by field_key. Defaults to all. */
fields?: string[];
/** Columns in the definition grid. @default 2 */
columns?: 1 | 2 | 3;
}
/**
* Generic Zino detail view. Fetches `GET /app/{id}/view/detailview/{uid}` and
* renders config.fields as a labeled definition grid. Wire it to a specific
* view via the thin wrappers in this folder.
*/
export function DetailView({ client, dvUid, instanceId, title, fields, columns = 2 }: DetailViewProps) {
const [resp, setResp] = useState<DetailResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let live = true;
async function run() {
setLoading(true);
setError(null);
try {
const r = await client.detailView(dvUid, instanceId);
if (live) setResp(r as DetailResponse);
} catch (e) {
if (live) setError((e as { message?: string })?.message ?? 'Failed to load');
} finally {
if (live) setLoading(false);
}
}
run();
return () => {
live = false;
};
}, [client, dvUid, instanceId]);
const all = resp?.config.fields ?? [];
const shown = fields
? fields
.map((k) => all.find((f) => f.field_key === k) ?? { field_key: k, output_label: k, data_type: 'string' })
: all;
const gridCols = { 1: 'grid-cols-1', 2: 'grid-cols-1 sm:grid-cols-2', 3: 'grid-cols-1 sm:grid-cols-3' }[columns];
return (
<Card title={title}>
{error ? (
<EmptyState title="Couldnt load record" hint={error} />
) : loading ? (
<div className="py-8 flex justify-center">
<Spinner label="Loading…" />
</div>
) : shown.length === 0 ? (
<EmptyState title="No fields to display" />
) : (
<dl className={cn('grid gap-x-6 gap-y-4', gridCols)}>
{shown.map((f) => (
<div key={f.field_key} className="flex flex-col gap-1 min-w-0">
<dt className="text-[10px] font-bold uppercase tracking-[0.06em] text-faint">{f.output_label}</dt>
<dd className="m-0 text-sm text-strong font-medium break-words">
{formatValue(resp?.data[f.field_key])}
</dd>
</div>
))}
</dl>
)}
</Card>
);
}

View File

@ -0,0 +1,21 @@
import { orderBookingClient } from '../../api/clients';
import { ORDER_BOOKING } from '../../api/config';
import { DetailView } from './DetailView';
export interface WiredDetailViewProps {
instanceId: number | string;
columns?: 1 | 2 | 3;
}
/** Order detail view (Order Booking workflow). */
export function OrderDetail({ instanceId, columns }: WiredDetailViewProps) {
return (
<DetailView
client={orderBookingClient}
dvUid={ORDER_BOOKING.detailViews.ORDERS}
instanceId={instanceId}
title="Order"
columns={columns}
/>
);
}

View File

@ -0,0 +1,17 @@
import { storeClient } from '../../api/clients';
import { STORE } from '../../api/config';
import { DetailView } from './DetailView';
import type { WiredDetailViewProps } from './OrderDetail';
/** Store detail view (Store workflow). */
export function StoreDetail({ instanceId, columns }: WiredDetailViewProps) {
return (
<DetailView
client={storeClient}
dvUid={STORE.detailViews.STORE}
instanceId={instanceId}
title="Store"
columns={columns}
/>
);
}

View File

@ -0,0 +1,7 @@
export { DetailView } from './DetailView';
export type { DetailViewProps } from './DetailView';
export { OrderDetail } from './OrderDetail';
export type { WiredDetailViewProps } from './OrderDetail';
export { CallDetail } from './CallDetail';
export { StoreDetail } from './StoreDetail';
export { DailyLogDetail } from './DailyLogDetail';

6
src/components/index.ts Normal file
View File

@ -0,0 +1,6 @@
// Krishna Sales component library. Single entry point:
// import { Button, Card, RecordView, OrdersView, StoreDetail } from './components';
export * from './buttons';
export * from './reusable';
export * from './rv';
export * from './dv';

View File

@ -0,0 +1,51 @@
import type { ReactNode } from 'react';
import { cn } from '../../lib/cn';
export type BadgeTone =
| 'neutral'
| 'navy'
| 'success'
| 'warning'
| 'danger'
| 'info'
| 'accent';
export interface BadgeProps {
children?: ReactNode;
/** @default "neutral" */
tone?: BadgeTone;
/** Show a leading status dot. @default false */
dot?: boolean;
/** @default "md" */
size?: 'sm' | 'md';
className?: string;
}
const TONES: Record<BadgeTone, { wrap: string; dot: string }> = {
neutral: { wrap: 'bg-slate-100 text-slate-700', dot: 'bg-slate-500' },
navy: { wrap: 'bg-navy-900 text-white', dot: 'bg-sunrise-400' },
success: { wrap: 'bg-emerald-100 text-emerald-700', dot: 'bg-emerald-600' },
warning: { wrap: 'bg-amber-100 text-amber-700', dot: 'bg-amber-600' },
danger: { wrap: 'bg-ruby-100 text-ruby-700', dot: 'bg-ruby-600' },
info: { wrap: 'bg-sky-100 text-sky-700', dot: 'bg-sky-600' },
accent: { wrap: 'bg-sunrise-100 text-sunrise-600', dot: 'bg-sunrise-500' },
};
/** Compact status pill with optional leading dot. */
export function Badge({ children, tone = 'neutral', dot = false, size = 'md', className }: BadgeProps) {
const t = TONES[tone];
const sm = size === 'sm';
return (
<span
className={cn(
'inline-flex items-center gap-1.5 rounded-pill font-sans font-semibold leading-none whitespace-nowrap',
sm ? 'text-2xs px-2 py-1' : 'text-xs px-2.5 py-[5px]',
t.wrap,
className,
)}
>
{dot && <span className={cn('w-1.5 h-1.5 rounded-full', t.dot)} />}
{children}
</span>
);
}

View File

@ -0,0 +1,58 @@
import type { CSSProperties, ReactNode } from 'react';
import { cn } from '../../lib/cn';
export type Elevation = 'none' | 'xs' | 'sm' | 'md' | 'lg';
export interface CardProps {
children?: ReactNode;
/** Optional header title. */
title?: ReactNode;
/** Optional header trailing node (button, menu). */
action?: ReactNode;
/** Apply body padding. @default true */
pad?: boolean;
/** @default "sm" */
elevation?: Elevation;
className?: string;
bodyClassName?: string;
style?: CSSProperties;
}
const SHADOW: Record<Elevation, string> = {
none: 'shadow-none',
xs: 'shadow-xs',
sm: 'shadow-sm',
md: 'shadow-md',
lg: 'shadow-lg',
};
/** Surface container with soft elevation. Optional header & padding control. */
export function Card({
children,
title,
action,
pad = true,
elevation = 'sm',
className,
bodyClassName,
style,
}: CardProps) {
return (
<section
className={cn(
'bg-card rounded-lg border border-border-subtle overflow-hidden font-sans',
SHADOW[elevation],
className,
)}
style={style}
>
{title && (
<header className="flex items-center justify-between px-5 py-4 border-b border-border-subtle">
<h3 className="m-0 text-md font-semibold text-strong">{title}</h3>
{action}
</header>
)}
<div className={cn(pad ? 'p-5' : 'p-0', bodyClassName)}>{children}</div>
</section>
);
}

View File

@ -0,0 +1,24 @@
import type { ReactNode } from 'react';
import { cn } from '../../lib/cn';
export interface EmptyStateProps {
/** Optional leading icon. */
icon?: ReactNode;
title: string;
hint?: string;
/** Optional action (e.g. a Button). */
action?: ReactNode;
className?: string;
}
/** Centered placeholder for empty lists / error fallbacks. */
export function EmptyState({ icon, title, hint, action, className }: EmptyStateProps) {
return (
<div className={cn('flex flex-col items-center justify-center gap-2 px-6 py-12 text-center', className)}>
{icon && <div className="text-faint mb-1">{icon}</div>}
<div className="text-sm font-semibold text-strong">{title}</div>
{hint && <div className="text-xs text-faint max-w-[36ch]">{hint}</div>}
{action && <div className="mt-3">{action}</div>}
</div>
);
}

View File

@ -0,0 +1,60 @@
import type { InputHTMLAttributes, ReactNode } from 'react';
import { cn } from '../../lib/cn';
export interface InputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'prefix'> {
label?: string;
hint?: string;
error?: string;
/** Leading adornment, e.g. "₹". */
prefix?: ReactNode;
/** Trailing adornment, e.g. "/ year". */
suffix?: ReactNode;
/** Class for the outer label wrapper. */
className?: string;
}
/** Labeled text/number input with optional prefix, suffix, hint and error. */
export function Input({
label,
hint,
error,
prefix,
suffix,
required,
className,
disabled,
...rest
}: InputProps) {
return (
<label className={cn('flex flex-col gap-1.5 font-sans', disabled && 'cursor-not-allowed', className)}>
{label && (
<span className="text-sm font-medium text-muted">
{label}
{required && <span className="text-ruby-600"> *</span>}
</span>
)}
<div
className={cn(
'flex items-center gap-2 rounded-md px-3 h-[42px] border transition-[border-color,box-shadow] duration-150',
disabled ? 'bg-sunk border-border-subtle' : 'bg-card',
error ? 'border-ruby-600' : !disabled && 'border-border-default focus-ring',
)}
>
{prefix && <span className="text-base font-semibold text-faint">{prefix}</span>}
<input
required={required}
disabled={disabled}
className={cn(
'flex-1 w-full border-none outline-none bg-transparent font-sans text-base',
disabled ? 'text-muted cursor-not-allowed' : 'text-strong',
)}
{...rest}
/>
{suffix && <span className="text-faint text-sm">{suffix}</span>}
</div>
{(hint || error) && (
<span className={cn('text-xs', error ? 'text-ruby-600' : 'text-faint')}>{error || hint}</span>
)}
</label>
);
}

View File

@ -0,0 +1,77 @@
import { useEffect, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { X } from 'lucide-react';
import { cn } from '../../lib/cn';
export type ModalWidth = 'sm' | 'md' | 'lg' | 'xl';
const WIDTH: Record<ModalWidth, string> = {
sm: 'max-w-[480px]',
md: 'max-w-[640px]',
lg: 'max-w-[820px]',
xl: 'max-w-[1000px]',
};
export interface ModalProps {
open: boolean;
onClose: () => void;
title?: ReactNode;
subtitle?: ReactNode;
/** @default "md" */
width?: ModalWidth;
children?: ReactNode;
}
/** Portal modal host — backdrop, Esc / click-out close, scroll-locked body. */
export function Modal({ open, onClose, title, subtitle, width = 'md', children }: ModalProps) {
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', onKey);
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.removeEventListener('keydown', onKey);
document.body.style.overflow = prev;
};
}, [open, onClose]);
if (!open) return null;
return createPortal(
<div
className="fixed inset-0 z-[10000] flex items-start justify-center overflow-y-auto bg-black/40 p-4 sm:p-8"
onMouseDown={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div
role="dialog"
aria-modal="true"
className={cn(
'w-full bg-card rounded-lg border border-border-subtle shadow-lg my-auto',
'flex flex-col max-h-[85vh]',
WIDTH[width],
)}
>
<header className="shrink-0 flex items-start justify-between gap-3 px-5 py-4 border-b border-border-subtle">
<div className="min-w-0">
{title && <h3 className="m-0 text-md font-semibold text-strong truncate">{title}</h3>}
{subtitle && <div className="text-xs text-faint mt-0.5">{subtitle}</div>}
</div>
<button
onClick={onClose}
aria-label="Close"
className="shrink-0 -mr-1 -mt-1 p-1.5 rounded-md text-faint hover:text-strong hover:bg-black/5"
>
<X size={18} />
</button>
</header>
<div className="flex-1 min-h-0 overflow-y-auto p-5 scrollbar-slim">{children}</div>
</div>
</div>,
document.body,
);
}

View File

@ -0,0 +1,41 @@
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { Button } from '../buttons/Button';
export interface PaginationProps {
/** 1-based current page. */
page: number;
pageSize: number;
/** Total rows across all pages. */
total: number;
onPage: (page: number) => void;
}
/** Pager: "Showing ab of N" + Prev/Next. Renders nothing when the set fits on
* one page. Shared by the record-view tables. */
export function Pagination({ page, pageSize, total, onPage }: PaginationProps) {
if (total <= pageSize) return null;
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const safePage = Math.min(Math.max(page, 1), totalPages);
const start = (safePage - 1) * pageSize;
return (
<div className="flex items-center justify-between gap-3 border-t border-border-subtle px-[18px] py-3">
<span className="text-xs text-faint">
Showing {start + 1}{Math.min(start + pageSize, total)} of {total}
</span>
<div className="flex items-center gap-2">
<Button variant="secondary" size="sm" disabled={safePage <= 1} onClick={() => onPage(safePage - 1)}>
<ChevronLeft size={15} />
Prev
</Button>
<span className="px-1 text-xs font-medium text-muted nums">
{safePage} / {totalPages}
</span>
<Button variant="secondary" size="sm" disabled={safePage >= totalPages} onClick={() => onPage(safePage + 1)}>
Next
<ChevronRight size={15} />
</Button>
</div>
</div>
);
}

View File

@ -0,0 +1,50 @@
import type { SelectHTMLAttributes } from 'react';
import { ChevronDown } from 'lucide-react';
import { cn } from '../../lib/cn';
export interface SelectOption {
value: string;
label: string;
}
export interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
label?: string;
hint?: string;
/** Either strings or {value,label} objects. */
options?: Array<string | SelectOption>;
/** Class for the outer label wrapper. */
className?: string;
}
/** Labeled native select styled to match Input. */
export function Select({ label, hint, options = [], required, className, ...rest }: SelectProps) {
return (
<label className={cn('flex flex-col gap-1.5 font-sans', className)}>
{label && (
<span className="text-sm font-medium text-muted">
{label}
{required && <span className="text-ruby-600"> *</span>}
</span>
)}
<div className="relative bg-card rounded-md h-[42px] border border-border-default transition-[border-color,box-shadow] duration-150 focus-ring">
<select
required={required}
className="w-full h-full border-none outline-none bg-transparent appearance-none pl-3 pr-9 font-sans text-base text-strong cursor-pointer"
{...rest}
>
{options.map((o) => {
const val = typeof o === 'string' ? o : o.value;
const lab = typeof o === 'string' ? o : o.label;
return (
<option key={val} value={val}>
{lab}
</option>
);
})}
</select>
<ChevronDown size={15} className="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none text-faint" />
</div>
{hint && <span className="text-xs text-faint">{hint}</span>}
</label>
);
}

View File

@ -0,0 +1,20 @@
import { Loader2 } from 'lucide-react';
import { cn } from '../../lib/cn';
export interface SpinnerProps {
/** Pixel size of the icon. @default 18 */
size?: number;
/** Optional caption shown beside the spinner. */
label?: string;
className?: string;
}
/** Inline loading indicator. */
export function Spinner({ size = 18, label, className }: SpinnerProps) {
return (
<span className={cn('inline-flex items-center gap-2 text-faint', className)}>
<Loader2 size={size} className="animate-spin" />
{label && <span className="text-sm">{label}</span>}
</span>
);
}

View File

@ -0,0 +1,16 @@
export { Card } from './Card';
export type { CardProps, Elevation } from './Card';
export { Badge } from './Badge';
export type { BadgeProps, BadgeTone } from './Badge';
export { Input } from './Input';
export type { InputProps } from './Input';
export { Select } from './Select';
export type { SelectProps, SelectOption } from './Select';
export { Pagination } from './Pagination';
export type { PaginationProps } from './Pagination';
export { Modal } from './Modal';
export type { ModalProps, ModalWidth } from './Modal';
export { Spinner } from './Spinner';
export type { SpinnerProps } from './Spinner';
export { EmptyState } from './EmptyState';
export type { EmptyStateProps } from './EmptyState';

View File

@ -0,0 +1,17 @@
import { orderBookingClient } from '../../api/clients';
import { ORDER_BOOKING } from '../../api/config';
import { RecordView } from './RecordView';
import type { WiredRecordViewProps } from './OrdersView';
/** Calls record view (Order Booking workflow). */
export function CallsView({ onRowClick, pageSize }: WiredRecordViewProps) {
return (
<RecordView
client={orderBookingClient}
rvUid={ORDER_BOOKING.recordViews.CALLS}
title="Calls"
onRowClick={onRowClick}
pageSize={pageSize}
/>
);
}

View File

@ -0,0 +1,17 @@
import { dailyReportsClient } from '../../api/clients';
import { DAILY_REPORTS } from '../../api/config';
import { RecordView } from './RecordView';
import type { WiredRecordViewProps } from './OrdersView';
/** Daily Logs record view (Daily Reports workflow). */
export function DailyLogsView({ onRowClick, pageSize }: WiredRecordViewProps) {
return (
<RecordView
client={dailyReportsClient}
rvUid={DAILY_REPORTS.recordViews.DAILY_LOGS}
title="Daily Logs"
onRowClick={onRowClick}
pageSize={pageSize}
/>
);
}

View File

@ -0,0 +1,21 @@
import { orderBookingClient } from '../../api/clients';
import { ORDER_BOOKING } from '../../api/config';
import { RecordView } from './RecordView';
export interface WiredRecordViewProps {
onRowClick?: (row: Record<string, unknown>, index: number) => void;
pageSize?: number;
}
/** Orders record view (Order Booking workflow). */
export function OrdersView({ onRowClick, pageSize }: WiredRecordViewProps) {
return (
<RecordView
client={orderBookingClient}
rvUid={ORDER_BOOKING.recordViews.ORDERS}
title="Orders"
onRowClick={onRowClick}
pageSize={pageSize}
/>
);
}

View File

@ -0,0 +1,158 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { Search } from 'lucide-react';
import { cn } from '../../lib/cn';
import { formatValue } from '../../lib/format';
import type { ZinoClient } from '../../api/client';
import type { RecordViewField, RecordViewResponse } from '../../api/types';
import { Card } from '../reusable/Card';
import { Pagination } from '../reusable/Pagination';
import { Spinner } from '../reusable/Spinner';
import { EmptyState } from '../reusable/EmptyState';
export interface RecordViewProps {
/** Workflow-bound client (see api/clients.ts). */
client: ZinoClient;
/** rv_template_uid for this view. */
rvUid: string;
/** Card header title. */
title?: string;
/** Restrict/order visible columns by field_key. Defaults to all fields. */
columns?: string[];
/** Rows per page. @default 25 */
pageSize?: number;
/** Click handler — receives the raw row + index. */
onRowClick?: (row: Record<string, unknown>, index: number) => void;
/** Extract a stable key for a row. @default row.instance_id ?? index */
rowKey?: (row: Record<string, unknown>, index: number) => string;
}
/**
* Generic Zino record-view table. Fetches `POST /app/{id}/view/recordview`
* (server-side paginated + searchable) and renders config.fields as columns.
* Wire it to a specific view via the thin wrappers in this folder.
*/
export function RecordView({
client,
rvUid,
title,
columns,
pageSize = 25,
onRowClick,
rowKey,
}: RecordViewProps) {
const [page, setPage] = useState(1);
const [search, setSearch] = useState('');
const [debounced, setDebounced] = useState('');
const [resp, setResp] = useState<RecordViewResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Debounce the search box; reset to page 1 whenever the term changes.
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (timer.current) clearTimeout(timer.current);
timer.current = setTimeout(() => {
setDebounced(search);
setPage(1);
}, 300);
return () => {
if (timer.current) clearTimeout(timer.current);
};
}, [search]);
useEffect(() => {
let live = true;
async function run() {
setLoading(true);
setError(null);
try {
const r = await client.recordView(rvUid, { page, limit: pageSize, search: debounced });
if (live) setResp(r);
} catch (e) {
if (live) setError((e as { message?: string })?.message ?? 'Failed to load');
} finally {
if (live) setLoading(false);
}
}
run();
return () => {
live = false;
};
}, [client, rvUid, page, pageSize, debounced]);
const fields: RecordViewField[] = useMemo(() => {
const all = resp?.config.fields ?? [];
if (!columns) return all;
const byKey = new Map(all.map((f) => [f.field_key, f]));
return columns
.map((k) => byKey.get(k) ?? ({ field_key: k, output_label: k, data_type: 'string', is_filter: false, is_search: false } as RecordViewField))
.filter(Boolean);
}, [resp, columns]);
const rows = resp?.data ?? [];
const total = resp?.pagination?.total_count ?? rows.length;
return (
<Card
title={title}
pad={false}
action={
<div className="relative w-[240px] max-w-full">
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-faint pointer-events-none" />
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search…"
className="w-full h-9 rounded-md border border-border-default bg-card pl-9 pr-3 font-sans text-sm text-strong outline-none focus-ring"
/>
</div>
}
>
{error ? (
<EmptyState title="Couldnt load records" hint={error} />
) : loading && !resp ? (
<div className="p-10 flex justify-center">
<Spinner label="Loading records…" />
</div>
) : rows.length === 0 ? (
<EmptyState title="No records" hint={debounced ? 'Nothing matches your search.' : undefined} />
) : (
<div className="overflow-x-auto scrollbar-slim">
<table className="w-full border-collapse min-w-[640px]">
<thead>
<tr className="bg-slate-50 border-b border-border-subtle">
{fields.map((f) => (
<th
key={f.field_key}
className="text-left px-[18px] py-[11px] text-[10px] font-bold uppercase tracking-[0.06em] text-faint whitespace-nowrap"
>
{f.output_label}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, i) => (
<tr
key={rowKey ? rowKey(row, i) : String(row.instance_id ?? i)}
onClick={onRowClick ? () => onRowClick(row, i) : undefined}
className={cn(
'border-b border-border-subtle transition-colors duration-150',
onRowClick && 'cursor-pointer hover:bg-slate-50',
)}
>
{fields.map((f) => (
<td key={f.field_key} className="px-[18px] py-3 text-sm text-body whitespace-nowrap">
{formatValue(row[f.field_key])}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)}
<Pagination page={page} pageSize={pageSize} total={total} onPage={setPage} />
</Card>
);
}

View File

@ -0,0 +1,17 @@
import { storeClient } from '../../api/clients';
import { STORE } from '../../api/config';
import { RecordView } from './RecordView';
import type { WiredRecordViewProps } from './OrdersView';
/** Store record view (Store workflow). */
export function StoresView({ onRowClick, pageSize }: WiredRecordViewProps) {
return (
<RecordView
client={storeClient}
rvUid={STORE.recordViews.STORE}
title="Stores"
onRowClick={onRowClick}
pageSize={pageSize}
/>
);
}

View File

@ -0,0 +1,7 @@
export { RecordView } from './RecordView';
export type { RecordViewProps } from './RecordView';
export { OrdersView } from './OrdersView';
export type { WiredRecordViewProps } from './OrdersView';
export { CallsView } from './CallsView';
export { StoresView } from './StoresView';
export { DailyLogsView } from './DailyLogsView';

View File

@ -0,0 +1,363 @@
# Daily Reports — Data API Docs
**App ID:** `434`
**Version UUID:** `d4d4c738-bf74-4b44-a297-9a6653458f3a`
**Workflow UUID:** `c420dc04-2ba3-488e-902d-66bc9a2032cd`
## Base URLs
| Key | URL |
| ---- | ---------------------------- |
| core | `https://sandbox.getzino.in` |
| view | `https://sandbox.getzino.in` |
| user | `https://sandbox.getzino.in` |
All requests require header `Authorization: Bearer <JWT_TOKEN>` (user session JWT from login).
---
## 1. Activity Submission
Calls that create an instance or submit an activity's form. Init = `/start`; subsequent = `/activity`. Upload/OCR fire during fill for file-bearing fields.
### 1.1 Init Activity (init)
Create a new workflow instance and run the init activity.
- **Method:** `POST`
- **Path:** `/app/434/start` (base: core)
- **Content-Type:** `application/json`
**Request body**
```json
{
"activity_id": "ac04a444-9b96-463d-bc91-447fa80b028f",
"data": {
"date": "2024-01-15",
"day_plan_notes": "string_value",
"field_1": "09:30",
"route_code": "option_value",
"sales_officer_name": "string_value",
"store_image": "<IMAGE_UPLOAD>",
"sub_route": "option_value"
},
"version": 1,
"workflow_uuid": "c420dc04-2ba3-488e-902d-66bc9a2032cd"
}
```
**Request fields**
| Key | Type | Required | Note |
| ------------------ | -------- | -------- | ----------------------------- |
| workflow_uuid | string | ✅ | Clone-portable workflow UID. |
| activity_id | string | ✅ | The init activity UID. |
| version | int | ❌ | Deployed workflow version. |
| data | object | ❌ | Field values keyed by field id. |
| sales_officer_name | app_user | ❌ | "Sales Officer Name" (app_user) |
| date | date | ❌ | "Date" (date) |
| field_1 | time | ❌ | "Time" (time) |
| route_code | select | ❌ | "Route Code" (select) |
| sub_route | select | ❌ | "Sub Route" (select) |
| store_image | image | ❌ | "Upload Store Image" (image) |
| day_plan_notes | longtext | ❌ | "Day Plan (notes)" (longtext) |
### 1.2 Punch Out
Advance an existing instance through this activity (normal submission).
- **Method:** `POST`
- **Path:** `/app/434/activity` (base: core)
- **Content-Type:** `application/json`
**Request body**
```json
{
"activity_id": "37c218fc-fd91-41d1-9964-fcaeade54e74",
"data": {
"eod_notes_remarks": "string_value",
"total_non_productive_calls": 123,
"total_productive_calls": 123
},
"instance_id": "<INSTANCE_ID>",
"workflow_uuid": "c420dc04-2ba3-488e-902d-66bc9a2032cd"
}
```
**Request fields**
| Key | Type | Required | Note |
| -------------------------- | -------- | -------- | ------------------------------------ |
| workflow_uuid | string | ✅ | Clone-portable workflow UID. |
| activity_id | string | ✅ | This activity's UID. |
| instance_id | int64 | ✅ | Target instance (runtime value). |
| data | object | ❌ | Field values keyed by field id. |
| total_productive_calls | number | ❌ | "Total Productive Calls" (number) |
| total_non_productive_calls | number | ❌ | "Total Non Productive Calls" (number) |
| eod_notes_remarks | longtext | ❌ | "EOD Notes (Remarks)" (longtext) |
### 1.3 Upload file
Upload a file/image during form fill; returns `blob_path` that replaces the File in the submit payload.
- **Method:** `POST`
- **Path:** `/app/434/upload` (base: core)
- **Content-Type:** `multipart/form-data` (browser sets the boundary automatically)
**Form fields**
| Key | Type | Required | Note |
| ------------- | ------ | -------- | ------------------------------------------- |
| file | binary | ✅ | The file contents. |
| workflow_uuid | string | ✅ | Fixed: `c420dc04-2ba3-488e-902d-66bc9a2032cd`. |
| activity_id | string | ✅ | |
| field_id | string | ✅ | |
| column_id | string | ❌ | Only for grid-cell file columns. |
| version_uuid | string | ❌ | |
| instance_id | int64 | ❌ | Runtime value when editing an instance. |
**Response body**
```json
{
"blob_path": "12/a1b2c3.png",
"mime_type": "image/png",
"original_name": "photo.png",
"size_bytes": 20480,
"url": "<STORAGE_URL>"
}
```
| Key | Type | Note |
| ------------- | ------ | --------------------------------------------- |
| blob_path | string | Storage path that replaces the File in `data[field_id]`. |
| original_name | string | |
| mime_type | string | |
| size_bytes | int64 | |
| url | string | |
### Common submission response
Applies to Init Activity and Punch Out.
```json
{
"data": {},
"instance_id": 1024,
"message": "Activity completed successfully",
"status_code": 200,
"success": true
}
```
| Key | Type | Note |
| ----------- | ------ | --------------------------------------------------------------- |
| success | bool | Whether the activity ran. |
| status_code | int | HTTP status; mirrors a Response node if the trigger graph has one. |
| message | string | Human-readable result; from a Response node when present. |
| data | object | Response-node payload; empty object by default. |
| instance_id | int64 | The workflow instance affected/created. |
---
## 2. View APIs
Read endpoints that power record/detail/activity/chart views, segregated by source type with each view's UI name.
### 2.1 Record Views
Paginated records + tiles + charts.
- **Method:** `POST`
- **Path:** `/app/434/view/recordview` (base: view)
- **Content-Type:** `application/json`
| UI Name | rv_template_uid |
| ---------- | -------------------------------------- |
| Daily Logs | `269da629-1701-477a-a37b-a78bb056d4b3` |
**Request body**
```json
{
"params": {},
"preset_alias": "",
"rv_template_uid": "269da629-1701-477a-a37b-a78bb056d4b3",
"search_query": {
"filters": [],
"limit": 25,
"page": 1,
"search": "",
"sort_by": "",
"sort_dir": "desc"
}
}
```
**Request fields**
| Key | Type | Required | Note |
| --------------------- | ------ | -------- | ----------------------------------------------------------- |
| rv_template_uid | string | ✅ | This view's template UID. |
| preset_alias | string | ❌ | Optional; selects a named prefilter preset on this view. |
| params | object | ❌ | Values for declared input params (`${input.<key>}` refs). |
| search_query.page | int | ❌ | 1-based page. |
| search_query.limit | int | ❌ | Default 25, max 200. |
| search_query.sort_by | string | ❌ | `field_key` \| `created_at` \| `updated_at` \| `instance_id`. |
| search_query.sort_dir | string | ❌ | `asc` \| `desc`. |
| search_query.search | string | ❌ | ILIKE term across searchable fields. |
| search_query.filters | array | ❌ | `[{field_key, value, value2?, data_type}]`. |
**Response body**
```json
{
"chart_data": [],
"data": [],
"pagination": { "limit": 25, "page": 1, "total_records": 0 },
"tile_values": {}
}
```
| Key | Type | Note |
| ----------- | ------ | ------------------------------- |
| data | array | Companion-table rows. |
| tile_values | object | |
| chart_data | array | |
| pagination | object | `{page, limit, total_records}`. |
### 2.2 Detail Views
Single-instance detail record.
- **Method:** `GET`
- **Base:** view
| UI Name | Path |
| ---------- | ---------------------------------------------------------------------------- |
| Daily Logs | `/app/434/view/detailview/26b3c6de-a1d7-4f71-be88-7e4929932b0b?instance_id=<INSTANCE_ID>` |
**Request fields**
| Key | Type | Required | Note |
| ----------- | ----- | -------- | ----------------------------------------------------------------- |
| instance_id | int64 | ✅ | Query param (runtime value); some DVs use declared extra_params. |
**Response body**
```json
{ "config": {}, "data": {} }
```
| Key | Type | Note |
| ------ | ------ | ----------------- |
| config | object | |
| data | object | `field_key→value`. |
---
## 3. In-form Activity Helper APIs
Calls a form makes while being filled, segregated per activity: lookup, workflow-lookup, app-user, dataset options, field actions, and peer-instance prefetch.
### Activity: Init Activity (`ac04a444-9b96-463d-bc91-447fa80b028f`)
#### App User — Sales Officer Name
App-user dropdown: open / search.
- **Method:** `POST`
- **Path:** `/app/434/app-user/records` (base: core)
- **Content-Type:** `application/json`
**Request body**
```json
{
"activity_id": "ac04a444-9b96-463d-bc91-447fa80b028f",
"field_id": "sales_officer_name",
"form_data": {},
"limit": 50,
"offset": 0,
"search": "",
"workflow_uuid": "c420dc04-2ba3-488e-902d-66bc9a2032cd"
}
```
**Request fields**
| Key | Type | Required | Note |
| ------------- | ------ | -------- | ----------------------------------------------- |
| workflow_uuid | string | ✅ | Fixed: `c420dc04-2ba3-488e-902d-66bc9a2032cd`. |
| activity_id | string | ✅ | |
| field_id | string | ✅ | |
| search | string | ❌ | Search term. |
| limit | int | ❌ | Default 50, max 200. |
| offset | int | ❌ | |
| filters | array | ❌ | `[{column, operator, value}]`. |
| form_data | object | ❌ | Current form values for filter_options. |
| version_uuid | string | ❌ | Pins filter_options to the deployed version. |
**Response body**
```json
{ "limit": 50, "offset": 0, "records": [], "total": 0 }
```
| Key | Type | Note |
| ------- | ----- | ------------------------------------------------- |
| records | array | For dataset-options this is `options:[{value,label}]`. |
| total | int | |
| limit | int | |
| offset | int | |
#### Dataset options — Route Code
Dataset-backed options: mount + dependency change.
- **Method:** `POST`
- **Path:** `/app/434/dataset-options` (base: core)
- **Content-Type:** `application/json`
**Request body**
```json
{
"activity_id": "ac04a444-9b96-463d-bc91-447fa80b028f",
"field_id": "route_code",
"form_data": {},
"limit": 50,
"offset": 0,
"search": "",
"workflow_uuid": "c420dc04-2ba3-488e-902d-66bc9a2032cd"
}
```
**Request fields**
| Key | Type | Required | Note |
| ------------- | ------ | -------- | ----------------------------------------------- |
| workflow_uuid | string | ✅ | Fixed: `c420dc04-2ba3-488e-902d-66bc9a2032cd`. |
| activity_id | string | ✅ | |
| field_id | string | ✅ | |
| search | string | ❌ | Search term. |
| limit | int | ❌ | Default 50, max 200. |
| offset | int | ❌ | |
| filters | array | ❌ | `[{column, operator, value}]`. |
| form_data | object | ❌ | Current form values for filter_options. |
| version_uuid | string | ❌ | Pins filter_options to the deployed version. |
**Response body**
```json
{ "limit": 50, "offset": 0, "records": [], "total": 0 }
```
| Key | Type | Note |
| ------- | ----- | ------------------------------------------------- |
| records | array | For dataset-options this is `options:[{value,label}]`. |
| total | int | |
| limit | int | |
| offset | int | |

View File

@ -0,0 +1,456 @@
# Order Booking — Data API Docs
**App ID:** `434`
**Version UUID:** `f19b2146-88e5-448a-9a51-e517ebce680a`
**Workflow UUID:** `b5a21627-9422-40cf-b7f5-31fa070bf42f`
## Base URLs
| Key | URL |
| ---- | ---------------------------- |
| core | `https://sandbox.getzino.in` |
| view | `https://sandbox.getzino.in` |
| user | `https://sandbox.getzino.in` |
All requests require header `Authorization: Bearer <JWT_TOKEN>` (user session JWT from login).
---
## 1. Activity Submission
Calls that create an instance or submit an activity's form. Init = `/start`; subsequent = `/activity`. Upload/OCR fire during fill for file-bearing fields.
### 1.1 Log Visit (init)
Create a new workflow instance and run the init activity.
- **Method:** `POST`
- **Path:** `/app/434/start` (base: core)
- **Content-Type:** `application/json`
**Request body**
```json
{
"activity_id": "42b7f47d-96f0-4289-a6d9-38f455ad57dd",
"data": {
"date_of_visit": "2024-01-15",
"select_store": "string_value",
"time_of_visit": "09:30",
"upload_image": "<IMAGE_UPLOAD>"
},
"version": 1,
"workflow_uuid": "b5a21627-9422-40cf-b7f5-31fa070bf42f"
}
```
**Request fields**
| Key | Type | Required | Note |
| -------------- | --------- | -------- | ----------------------------- |
| workflow_uuid | string | ✅ | Clone-portable workflow UID. |
| activity_id | string | ✅ | The init activity UID. |
| version | int | ❌ | Deployed workflow version. |
| data | object | ❌ | Field values keyed by field id. |
| select_store | wf_lookup | ❌ | "Select Store" (wf_lookup) |
| date_of_visit | date | ❌ | "Date of Visit" (date) |
| time_of_visit | time | ❌ | "Time of Visit" (time) |
| upload_image | image | ❌ | "Upload Image" (image) |
### 1.2 Place Order
Advance an existing instance through this activity (normal submission).
- **Method:** `POST`
- **Path:** `/app/434/activity` (base: core)
- **Content-Type:** `application/json`
**Request body**
```json
{
"activity_id": "f66403d7-e31f-4d64-bcfd-ff5485aec8b8",
"data": {
"date_of_order": "2024-01-15",
"order_details": [
{
"bags": 123,
"br_code": "string_value",
"product_category": "option_value",
"product_name": "option_value",
"sku": "string_value",
"sku_code": "string_value"
}
],
"total_bags": 123,
"total_kgs": 123
},
"instance_id": "<INSTANCE_ID>",
"workflow_uuid": "b5a21627-9422-40cf-b7f5-31fa070bf42f"
}
```
**Request fields**
| Key | Type | Required | Note |
| ------------- | ------ | -------- | ----------------------------- |
| workflow_uuid | string | ✅ | Clone-portable workflow UID. |
| activity_id | string | ✅ | This activity's UID. |
| instance_id | int64 | ✅ | Target instance (runtime value). |
| data | object | ❌ | Field values keyed by field id. |
| date_of_order | date | ❌ | "Date of Order" (date) |
| order_details | grid | ❌ | "Order Details" (grid) |
| total_bags | number | ❌ | "Total bags" (number) |
| total_kgs | number | ❌ | "Total Kgs" (number) |
### 1.3 Productivity of Visit
Advance an existing instance through this activity (normal submission).
- **Method:** `POST`
- **Path:** `/app/434/activity` (base: core)
- **Content-Type:** `application/json`
**Request body**
```json
{
"activity_id": "11bd10f9-a001-470e-867f-33dee8eabe4b",
"data": {
"action": "string_value",
"is_productive_call": "string_value",
"order_received_channel": "on_call",
"remarks": "string_value",
"store_status": "opened"
},
"instance_id": "<INSTANCE_ID>",
"workflow_uuid": "b5a21627-9422-40cf-b7f5-31fa070bf42f"
}
```
**Request fields**
| Key | Type | Required | Note |
| ---------------------- | -------- | -------- | -------------------------------- |
| workflow_uuid | string | ✅ | Clone-portable workflow UID. |
| activity_id | string | ✅ | This activity's UID. |
| instance_id | int64 | ✅ | Target instance (runtime value). |
| data | object | ❌ | Field values keyed by field id. |
| is_productive_call | radio | ❌ | "Is Productive Call?" (radio) |
| store_status | select | ❌ | "Store Status" (select) |
| order_received_channel | select | ❌ | "Order Received Channel" (select) |
| remarks | longtext | ❌ | "Remarks" (longtext) |
| action | radio | ❌ | "Action" (radio) |
### 1.4 Potential Mining
Advance an existing instance through this activity (normal submission).
- **Method:** `POST`
- **Path:** `/app/434/activity` (base: core)
- **Content-Type:** `application/json`
**Request body**
```json
{
"activity_id": "af8ac8df-d868-4f7d-88b2-123955d69c56",
"data": {
"potential": [
{
"actual_potential": 123,
"difference": 123,
"potential_remarks": "string_value",
"product_category_": "option_value",
"reason": "option_value",
"total_ordered": 123
}
]
},
"instance_id": "<INSTANCE_ID>",
"workflow_uuid": "b5a21627-9422-40cf-b7f5-31fa070bf42f"
}
```
**Request fields**
| Key | Type | Required | Note |
| ------------- | ------ | -------- | -------------------------------- |
| workflow_uuid | string | ✅ | Clone-portable workflow UID. |
| activity_id | string | ✅ | This activity's UID. |
| instance_id | int64 | ✅ | Target instance (runtime value). |
| data | object | ❌ | Field values keyed by field id. |
| potential | grid | ❌ | "Potential" (grid) |
### 1.5 Close Order
Advance an existing instance through this activity (normal submission).
- **Method:** `POST`
- **Path:** `/app/434/activity` (base: core)
- **Content-Type:** `application/json`
**Request body**
```json
{
"activity_id": "8580d7a2-5d31-40ab-b7ea-2a38d144e59a",
"data": {
"remarks_2": "string_value"
},
"instance_id": "<INSTANCE_ID>",
"workflow_uuid": "b5a21627-9422-40cf-b7f5-31fa070bf42f"
}
```
**Request fields**
| Key | Type | Required | Note |
| ------------- | -------- | -------- | -------------------------------- |
| workflow_uuid | string | ✅ | Clone-portable workflow UID. |
| activity_id | string | ✅ | This activity's UID. |
| instance_id | int64 | ✅ | Target instance (runtime value). |
| data | object | ❌ | Field values keyed by field id. |
| remarks_2 | longtext | ❌ | "Remarks" (longtext) |
### 1.6 Upload file
Upload a file/image during form fill; returns `blob_path` that replaces the File in the submit payload.
- **Method:** `POST`
- **Path:** `/app/434/upload` (base: core)
- **Content-Type:** `multipart/form-data` (browser sets the boundary automatically)
**Form fields**
| Key | Type | Required | Note |
| ------------- | ------ | -------- | ------------------------------------------- |
| file | binary | ✅ | The file contents. |
| workflow_uuid | string | ✅ | Fixed: `b5a21627-9422-40cf-b7f5-31fa070bf42f`. |
| activity_id | string | ✅ | |
| field_id | string | ✅ | |
| column_id | string | ❌ | Only for grid-cell file columns. |
| version_uuid | string | ❌ | |
| instance_id | int64 | ❌ | Runtime value when editing an instance. |
**Response body**
```json
{
"blob_path": "12/a1b2c3.png",
"mime_type": "image/png",
"original_name": "photo.png",
"size_bytes": 20480,
"url": "<STORAGE_URL>"
}
```
| Key | Type | Note |
| ------------- | ------ | --------------------------------------------- |
| blob_path | string | Storage path that replaces the File in `data[field_id]`. |
| original_name | string | |
| mime_type | string | |
| size_bytes | int64 | |
| url | string | |
### Common submission response
Applies to Log Visit, Place Order, Productivity of Visit, Potential Mining, Close Order.
```json
{
"data": {},
"instance_id": 1024,
"message": "Activity completed successfully",
"status_code": 200,
"success": true
}
```
| Key | Type | Note |
| ----------- | ------ | --------------------------------------------------------------- |
| success | bool | Whether the activity ran. |
| status_code | int | HTTP status; mirrors a Response node if the trigger graph has one. |
| message | string | Human-readable result; from a Response node when present. |
| data | object | Response-node payload; empty object by default. |
| instance_id | int64 | The workflow instance affected/created. |
---
## 2. View APIs
Read endpoints that power record/detail/activity/chart views, segregated by source type with each view's UI name.
### 2.1 Record Views
Paginated records + tiles + charts.
- **Method:** `POST`
- **Path:** `/app/434/view/recordview` (base: view)
- **Content-Type:** `application/json`
| UI Name | rv_template_uid |
| ------- | -------------------------------------- |
| Orders | `1e424561-9a27-44f5-9b68-64d63296d837` |
| Calls | `b4d7a710-24e7-416d-885a-31fd1e727aab` |
**Request body**
```json
{
"params": {},
"preset_alias": "",
"rv_template_uid": "1e424561-9a27-44f5-9b68-64d63296d837",
"search_query": {
"filters": [],
"limit": 25,
"page": 1,
"search": "",
"sort_by": "",
"sort_dir": "desc"
}
}
```
**Request fields**
| Key | Type | Required | Note |
| --------------------- | ------ | -------- | ----------------------------------------------------------- |
| rv_template_uid | string | ✅ | This view's template UID. |
| preset_alias | string | ❌ | Optional; selects a named prefilter preset on this view. |
| params | object | ❌ | Values for declared input params (`${input.<key>}` refs). |
| search_query.page | int | ❌ | 1-based page. |
| search_query.limit | int | ❌ | Default 25, max 200. |
| search_query.sort_by | string | ❌ | `field_key` \| `created_at` \| `updated_at` \| `instance_id`. |
| search_query.sort_dir | string | ❌ | `asc` \| `desc`. |
| search_query.search | string | ❌ | ILIKE term across searchable fields. |
| search_query.filters | array | ❌ | `[{field_key, value, value2?, data_type}]`. |
**Response body**
```json
{
"chart_data": [],
"data": [],
"pagination": { "limit": 25, "page": 1, "total_records": 0 },
"tile_values": {}
}
```
| Key | Type | Note |
| ----------- | ------ | ------------------------------- |
| data | array | Companion-table rows. |
| tile_values | object | |
| chart_data | array | |
| pagination | object | `{page, limit, total_records}`. |
### 2.2 Detail Views
Single-instance detail record.
- **Method:** `GET`
- **Base:** view
| UI Name | Path |
| ------- | ---------------------------------------------------------------------------- |
| Orders | `/app/434/view/detailview/0804c6c3-6cf9-4050-94bb-fa48dd5de87d?instance_id=<INSTANCE_ID>` |
| Calls | `/app/434/view/detailview/09a32bfe-9e43-4e0f-bb7d-db89c8a2557c?instance_id=<INSTANCE_ID>` |
**Request fields**
| Key | Type | Required | Note |
| ----------- | ----- | -------- | ----------------------------------------------------------------- |
| instance_id | int64 | ✅ | Query param (runtime value); some DVs use declared extra_params. |
**Response body**
```json
{ "config": {}, "data": {} }
```
| Key | Type | Note |
| ------ | ------ | ----------------- |
| config | object | |
| data | object | `field_key→value`. |
---
## 3. In-form Activity Helper APIs
Calls a form makes while being filled, segregated per activity: lookup, workflow-lookup, app-user, dataset options, field actions, and peer-instance prefetch.
### Activity: Log Visit (`42b7f47d-96f0-4289-a6d9-38f455ad57dd`)
#### WF Lookup — Select Store
Workflow-lookup dropdown: open / search / dependency change.
- **Method:** `POST`
- **Path:** `/app/434/wf-lookup/records` (base: core)
- **Content-Type:** `application/json`
**Request body**
```json
{
"activity_id": "42b7f47d-96f0-4289-a6d9-38f455ad57dd",
"field_id": "select_store",
"form_data": {},
"limit": 50,
"offset": 0,
"search": "",
"workflow_uuid": "b5a21627-9422-40cf-b7f5-31fa070bf42f"
}
```
**Request fields**
| Key | Type | Required | Note |
| ------------- | ------ | -------- | ----------------------------------------------- |
| workflow_uuid | string | ✅ | Fixed: `b5a21627-9422-40cf-b7f5-31fa070bf42f`. |
| activity_id | string | ✅ | |
| field_id | string | ✅ | |
| search | string | ❌ | Search term. |
| limit | int | ❌ | Default 50, max 200. |
| offset | int | ❌ | |
| filters | array | ❌ | `[{column, operator, value}]`. |
| form_data | object | ❌ | Current form values for filter_options. |
| version_uuid | string | ❌ | Pins filter_options to the deployed version. |
**Response body**
```json
{ "limit": 50, "offset": 0, "records": [], "total": 0 }
```
| Key | Type | Note |
| ------- | ----- | ------------------------------------------------- |
| records | array | For dataset-options this is `options:[{value,label}]`. |
| total | int | |
| limit | int | |
| offset | int | |
#### Peer instance data
Form-open prefetch of a peer instance referenced by field_rules / compute_value chains.
- **Method:** `GET`
- **Path:** `/app/434/view/instance-data?workflow_id=<PEER_WORKFLOW_ID>&instance_id=<INSTANCE_ID>` (base: view)
**Request fields**
| Key | Type | Required | Note |
| ----------- | ----- | -------- | ------------------------------------------------------------------- |
| workflow_id | int64 | ✅ | Query param; the peer/target workflow's runtime id (not this one). |
| instance_id | int64 | ✅ | Query param; peer instance id. |
**Response body**
```json
{ "instance": {}, "workflow_fields": {} }
```
| Key | Type | Note |
| --------------- | ------ | ---- |
| instance | object | |
| workflow_fields | object | |

386
src/docs/api/store.md Normal file
View File

@ -0,0 +1,386 @@
# Store — Data API Docs
**App ID:** `434`
**Version UUID:** `ced1fb5b-c6a4-4305-b85d-3c263d9d618e`
**Workflow UUID:** `2f827d27-b7a4-4ed9-9336-879fa46dbcba`
## Base URLs
| Key | URL |
| ---- | ---------------------------- |
| core | `https://sandbox.getzino.in` |
| view | `https://sandbox.getzino.in` |
| user | `https://sandbox.getzino.in` |
All requests require header `Authorization: Bearer <JWT_TOKEN>` (user session JWT from login).
---
## 1. Activity Submission
Calls that create an instance or submit an activity's form. Init = `/start`; subsequent = `/activity`. Upload/OCR fire during fill for file-bearing fields.
### 1.1 Init Activity (init)
Create a new workflow instance and run the init activity.
- **Method:** `POST`
- **Path:** `/app/434/start` (base: core)
- **Content-Type:** `application/json`
**Request body**
```json
{
"activity_id": "8626a77f-9e70-448e-8ac9-2c51eae5015b",
"data": {
"area_2": "string_value",
"business_name_2": "string_value",
"complete_address_2": "string_value",
"distributor_email_2": "user@example.com",
"distributor_name_2": "option_value",
"distributor_owner_name_2": "string_value",
"distributor_phone_number_2": {
"dial_code": "+1",
"phone": "2345678901",
"phone_with_dial_code": "+12345678901"
},
"email_2": "user@example.com",
"notes_2": "string_value",
"owner_name_2": "string_value",
"phone_number_2": {
"dial_code": "+1",
"phone": "2345678901",
"phone_with_dial_code": "+12345678901"
},
"pin_code_2": 123,
"potential_2": [
{ "product_category_1": "option_value", "quantity_1": 123 }
],
"route_code_2": "option_value",
"route_name_2": "option_value",
"store_image_2": "<IMAGE_UPLOAD>",
"store_location_2": "string_value",
"sub_route_2": "option_value"
},
"version": 1,
"workflow_uuid": "2f827d27-b7a4-4ed9-9336-879fa46dbcba"
}
```
**Request fields**
| Key | Type | Required | Note |
| -------------------------- | ----------- | -------- | -------------------------------- |
| workflow_uuid | string | ✅ | Clone-portable workflow UID. |
| activity_id | string | ✅ | The init activity UID. |
| version | int | ❌ | Deployed workflow version. |
| data | object | ❌ | Field values keyed by field id. |
| business_name_2 | text | ❌ | "Business Name" (text) |
| area_2 | text | ❌ | "Area" (text) |
| complete_address_2 | longtext | ❌ | "Complete Address" (longtext) |
| pin_code_2 | number | ❌ | "PIN Code" (number) |
| owner_name_2 | text | ❌ | "Owner Name" (text) |
| phone_number_2 | phone | ❌ | "Phone Number" (phone) |
| email_2 | email | ❌ | "Email" (email) |
| store_location_2 | geolocation | ❌ | "Store Location" (geolocation) |
| store_image_2 | image | ❌ | "Store Image" (image) |
| route_name_2 | select | ❌ | "Route Name" (select) |
| route_code_2 | select | ❌ | "Route Code" (select) |
| sub_route_2 | select | ❌ | "Sub Route" (select) |
| distributor_name_2 | select | ❌ | "Distributor Name" (select) |
| distributor_owner_name_2 | text | ❌ | "Distributor Owner Name" (text) |
| distributor_email_2 | email | ❌ | "Distributor Email" (email) |
| distributor_phone_number_2 | phone | ❌ | "Distributor Phone Number" (phone) |
| notes_2 | longtext | ❌ | "Notes" (longtext) |
| potential_2 | grid | ❌ | "Potential" (grid) |
### 1.2 Edit Store
Advance an existing instance through this activity (normal submission).
- **Method:** `POST`
- **Path:** `/app/434/activity` (base: core)
- **Content-Type:** `application/json`
**Request body**
```json
{
"activity_id": "87647fae-3b07-445f-9615-21881b163276",
"data": {
"area_3": "string_value",
"business_name_3": "string_value",
"complete_address_3": "string_value",
"distributor_email_3": "user@example.com",
"distributor_name_3": "option_value",
"distributor_owner_name_3": "string_value",
"distributor_phone_number_3": {
"dial_code": "+1",
"phone": "2345678901",
"phone_with_dial_code": "+12345678901"
},
"email_3": "user@example.com",
"notes_3": "string_value",
"owner_name_3": "string_value",
"phone_number_3": {
"dial_code": "+1",
"phone": "2345678901",
"phone_with_dial_code": "+12345678901"
},
"pin_code_3": 123,
"potential_3": [
{ "col_1": "option_value", "col_2": 123 }
],
"route_code_3": "option_value",
"route_name_3": "option_value",
"store_image_3": "<IMAGE_UPLOAD>",
"store_location_3": "string_value",
"sub_route_3": "option_value"
},
"instance_id": "<INSTANCE_ID>",
"workflow_uuid": "2f827d27-b7a4-4ed9-9336-879fa46dbcba"
}
```
**Request fields**
| Key | Type | Required | Note |
| -------------------------- | ----------- | -------- | -------------------------------- |
| workflow_uuid | string | ✅ | Clone-portable workflow UID. |
| activity_id | string | ✅ | This activity's UID. |
| instance_id | int64 | ✅ | Target instance (runtime value). |
| data | object | ❌ | Field values keyed by field id. |
| business_name_3 | text | ❌ | "Business Name" (text) |
| area_3 | text | ❌ | "Area" (text) |
| complete_address_3 | longtext | ❌ | "Complete Address" (longtext) |
| pin_code_3 | number | ❌ | "PIN Code" (number) |
| owner_name_3 | text | ❌ | "Owner Name" (text) |
| phone_number_3 | phone | ❌ | "Phone Number" (phone) |
| email_3 | email | ❌ | "Email" (email) |
| store_location_3 | geolocation | ❌ | "Store Location" (geolocation) |
| store_image_3 | image | ❌ | "Store Image" (image) |
| route_name_3 | select | ❌ | "Route Name" (select) |
| route_code_3 | select | ❌ | "Route Code" (select) |
| sub_route_3 | select | ❌ | "Sub Route" (select) |
| distributor_name_3 | select | ❌ | "Distributor Name" (select) |
| distributor_owner_name_3 | text | ❌ | "Distributor Owner Name" (text) |
| distributor_email_3 | email | ❌ | "Distributor Email" (email) |
| distributor_phone_number_3 | phone | ❌ | "Distributor Phone Number" (phone) |
| notes_3 | longtext | ❌ | "Notes" (longtext) |
| potential_3 | grid | ❌ | "Potential" (grid) |
### 1.3 Upload file
Upload a file/image during form fill; returns `blob_path` that replaces the File in the submit payload.
- **Method:** `POST`
- **Path:** `/app/434/upload` (base: core)
- **Content-Type:** `multipart/form-data` (browser sets the boundary automatically)
**Form fields**
| Key | Type | Required | Note |
| ------------- | ------ | -------- | ------------------------------------------- |
| file | binary | ✅ | The file contents. |
| workflow_uuid | string | ✅ | Fixed: `2f827d27-b7a4-4ed9-9336-879fa46dbcba`. |
| activity_id | string | ✅ | |
| field_id | string | ✅ | |
| column_id | string | ❌ | Only for grid-cell file columns. |
| version_uuid | string | ❌ | |
| instance_id | int64 | ❌ | Runtime value when editing an instance. |
**Response body**
```json
{
"blob_path": "12/a1b2c3.png",
"mime_type": "image/png",
"original_name": "photo.png",
"size_bytes": 20480,
"url": "<STORAGE_URL>"
}
```
| Key | Type | Note |
| ------------- | ------ | --------------------------------------------- |
| blob_path | string | Storage path that replaces the File in `data[field_id]`. |
| original_name | string | |
| mime_type | string | |
| size_bytes | int64 | |
| url | string | |
### Common submission response
Applies to Init Activity and Edit Store.
```json
{
"data": {},
"instance_id": 1024,
"message": "Activity completed successfully",
"status_code": 200,
"success": true
}
```
| Key | Type | Note |
| ----------- | ------ | --------------------------------------------------------------- |
| success | bool | Whether the activity ran. |
| status_code | int | HTTP status; mirrors a Response node if the trigger graph has one. |
| message | string | Human-readable result; from a Response node when present. |
| data | object | Response-node payload; empty object by default. |
| instance_id | int64 | The workflow instance affected/created. |
---
## 2. View APIs
Read endpoints that power record/detail/activity/chart views, segregated by source type with each view's UI name.
### 2.1 Record Views
Paginated records + tiles + charts.
- **Method:** `POST`
- **Path:** `/app/434/view/recordview` (base: view)
- **Content-Type:** `application/json`
| UI Name | rv_template_uid |
| ------- | -------------------------------------- |
| Store | `c03b411a-8454-4d8b-8220-00095e92a5de` |
**Request body**
```json
{
"params": {},
"preset_alias": "",
"rv_template_uid": "c03b411a-8454-4d8b-8220-00095e92a5de",
"search_query": {
"filters": [],
"limit": 25,
"page": 1,
"search": "",
"sort_by": "",
"sort_dir": "desc"
}
}
```
**Request fields**
| Key | Type | Required | Note |
| --------------------- | ------ | -------- | ----------------------------------------------------------- |
| rv_template_uid | string | ✅ | This view's template UID. |
| preset_alias | string | ❌ | Optional; selects a named prefilter preset on this view. |
| params | object | ❌ | Values for declared input params (`${input.<key>}` refs). |
| search_query.page | int | ❌ | 1-based page. |
| search_query.limit | int | ❌ | Default 25, max 200. |
| search_query.sort_by | string | ❌ | `field_key` \| `created_at` \| `updated_at` \| `instance_id`. |
| search_query.sort_dir | string | ❌ | `asc` \| `desc`. |
| search_query.search | string | ❌ | ILIKE term across searchable fields. |
| search_query.filters | array | ❌ | `[{field_key, value, value2?, data_type}]`. |
**Response body**
```json
{
"chart_data": [],
"data": [],
"pagination": { "limit": 25, "page": 1, "total_records": 0 },
"tile_values": {}
}
```
| Key | Type | Note |
| ----------- | ------ | ------------------------------- |
| data | array | Companion-table rows. |
| tile_values | object | |
| chart_data | array | |
| pagination | object | `{page, limit, total_records}`. |
### 2.2 Detail Views
Single-instance detail record.
- **Method:** `GET`
- **Base:** view
| UI Name | Path |
| ------- | ---------------------------------------------------------------------------- |
| Store | `/app/434/view/detailview/af868269-d340-4ea9-9793-5fabe4f6ae27?instance_id=<INSTANCE_ID>` |
**Request fields**
| Key | Type | Required | Note |
| ----------- | ----- | -------- | ----------------------------------------------------------------- |
| instance_id | int64 | ✅ | Query param (runtime value); some DVs use declared extra_params. |
**Response body**
```json
{ "config": {}, "data": {} }
```
| Key | Type | Note |
| ------ | ------ | ----------------- |
| config | object | |
| data | object | `field_key→value`. |
---
## 3. In-form Activity Helper APIs
Calls a form makes while being filled, segregated per activity: lookup, workflow-lookup, app-user, dataset options, field actions, and peer-instance prefetch.
### Activity: Init Activity (`8626a77f-9e70-448e-8ac9-2c51eae5015b`)
#### Dataset options — Route Name
Dataset-backed options: mount + dependency change.
- **Method:** `POST`
- **Path:** `/app/434/dataset-options` (base: core)
- **Content-Type:** `application/json`
**Request body**
```json
{
"activity_id": "8626a77f-9e70-448e-8ac9-2c51eae5015b",
"field_id": "route_name_2",
"form_data": {},
"limit": 50,
"offset": 0,
"search": "",
"workflow_uuid": "2f827d27-b7a4-4ed9-9336-879fa46dbcba"
}
```
**Request fields**
| Key | Type | Required | Note |
| ------------- | ------ | -------- | ----------------------------------------------- |
| workflow_uuid | string | ✅ | Fixed: `2f827d27-b7a4-4ed9-9336-879fa46dbcba`. |
| activity_id | string | ✅ | |
| field_id | string | ✅ | |
| search | string | ❌ | Search term. |
| limit | int | ❌ | Default 50, max 200. |
| offset | int | ❌ | |
| filters | array | ❌ | `[{column, operator, value}]`. |
| form_data | object | ❌ | Current form values for filter_options. |
| version_uuid | string | ❌ | Pins filter_options to the deployed version. |
**Response body**
```json
{ "limit": 50, "offset": 0, "records": [], "total": 0 }
```
| Key | Type | Note |
| ------- | ----- | ------------------------------------------------- |
| records | array | For dataset-options this is `options:[{value,label}]`. |
| total | int | |
| limit | int | |
| offset | int | |

4
src/lib/cn.ts Normal file
View File

@ -0,0 +1,4 @@
/** Join truthy class names. Tiny helper — no dependency needed. */
export function cn(...parts: Array<string | false | null | undefined>): string {
return parts.filter(Boolean).join(' ');
}

50
src/lib/format.ts Normal file
View File

@ -0,0 +1,50 @@
// Turn a raw record/detail-view field value into a display string. The Zino
// view APIs return heterogeneous shapes — scalars, phone objects, file blobs,
// {label,value} option objects, and arrays (grids) — so normalize them here.
interface PhoneLike {
phone_with_dial_code?: string;
phone?: string;
dial_code?: string;
}
function isObject(v: unknown): v is Record<string, unknown> {
return typeof v === 'object' && v !== null && !Array.isArray(v);
}
/** Best-effort single-line display string for a field value. */
export function formatValue(value: unknown): string {
if (value == null || value === '') return '—';
if (typeof value === 'boolean') return value ? 'Yes' : 'No';
if (typeof value === 'number' || typeof value === 'string') return String(value);
if (Array.isArray(value)) {
if (value.length === 0) return '—';
return `${value.length} item${value.length === 1 ? '' : 's'}`;
}
if (isObject(value)) {
const o = value as PhoneLike & Record<string, unknown>;
// Phone
if (o.phone_with_dial_code) return String(o.phone_with_dial_code);
if (o.phone) return `${o.dial_code ?? ''}${o.phone}`.trim();
// Option / lookup {label,value}
if ('label' in o && o.label != null) return String(o.label);
// File / image blob
if ('original_name' in o && o.original_name != null) return String(o.original_name);
if ('url' in o && o.url != null) return String(o.url);
if ('value' in o && o.value != null) return String(o.value);
}
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
/** Number with Indian-locale grouping; falls back to the raw string. */
export function formatNumber(value: unknown): string {
const n = typeof value === 'number' ? value : Number(value);
return Number.isFinite(n) ? n.toLocaleString('en-IN') : formatValue(value);
}

View File

@ -1,6 +1,6 @@
import { StrictMode } from 'react' import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import './index.css' import './styles/styles.css'
import App from './App.tsx' import App from './App.tsx'
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(

View File

@ -0,0 +1,76 @@
import { useEffect } from 'react';
import { NavLink, Navigate, Outlet, useNavigate } from 'react-router-dom';
import { LogOut } from 'lucide-react';
import { cn } from '../lib/cn';
import { useAuth } from '../auth/context';
import { onAuthErrorAll, orderBookingClient } from '../api/clients';
import { APP_ID } from '../api/config';
import { Button } from '../components/buttons';
import { TABS } from './tabs';
/** Auth-guarded shell: navy top bar + tab nav + routed <Outlet>. */
export function ConsoleLayout() {
const { authed, logout } = useAuth();
const navigate = useNavigate();
const user = orderBookingClient.currentUser();
// A 401 from any workflow client bounces back to login.
useEffect(() => {
onAuthErrorAll(() => {
logout();
navigate('/login', { replace: true });
});
}, [logout, navigate]);
if (!authed) return <Navigate to="/login" replace />;
return (
<div className="min-h-screen bg-app">
<header className="sticky top-0 z-10 flex items-center justify-between gap-3 px-6 h-[60px] bg-navy-grad border-b border-border-navy">
<div className="flex flex-col">
<span className="text-md font-extrabold text-on-navy tracking-[-0.01em] leading-none">Krishna Sales</span>
<span className="text-2xs text-on-navy-muted">Field Sales · Sandbox {APP_ID}</span>
</div>
<div className="flex items-center gap-3">
{user?.name && <span className="text-sm text-on-navy hidden sm:inline">{user.name}</span>}
<Button
variant="secondary"
size="sm"
iconLeft={<LogOut size={14} />}
onClick={() => {
logout();
navigate('/login', { replace: true });
}}
>
Sign out
</Button>
</div>
</header>
<main className="max-w-[1240px] mx-auto p-6 flex flex-col gap-5">
<nav className="flex gap-0.5 bg-slate-100 rounded-md p-[3px] w-fit">
{TABS.map((t) => {
const Icon = t.icon;
return (
<NavLink
key={t.key}
to={`/${t.key}`}
className={({ isActive }) =>
cn(
'inline-flex items-center gap-1.5 no-underline font-sans text-xs font-semibold px-3.5 py-2 rounded-sm transition-all duration-150',
isActive ? 'bg-card text-strong shadow-xs' : 'bg-transparent text-muted hover:text-strong',
)
}
>
<Icon size={14} />
{t.label}
</NavLink>
);
})}
</nav>
<Outlet />
</main>
</div>
);
}

53
src/screens/LoginPage.tsx Normal file
View File

@ -0,0 +1,53 @@
import { useState, type FormEvent } from 'react';
import { Navigate, useNavigate } from 'react-router-dom';
import { useAuth } from '../auth/context';
import { APP_ID } from '../api/config';
import { Button } from '../components/buttons';
import { Card, Input } from '../components/reusable';
/** Login gate. Redirects to /orders once authenticated. */
export function LoginPage() {
const { authed, login } = useAuth();
const navigate = useNavigate();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [orgId, setOrgId] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
if (authed) return <Navigate to="/orders" replace />;
async function submit(e: FormEvent) {
e.preventDefault();
setBusy(true);
setError(null);
try {
await login(email, password, orgId || undefined);
navigate('/orders', { replace: true });
} catch (err) {
setError((err as { message?: string })?.message ?? 'Login failed');
} finally {
setBusy(false);
}
}
return (
<div className="min-h-screen flex items-center justify-center p-4 bg-app">
<Card className="w-full max-w-[400px]">
<div className="flex flex-col gap-1 mb-5">
<h1 className="m-0 text-2xl font-extrabold text-strong tracking-[-0.02em]">Krishna Sales</h1>
<p className="m-0 text-sm text-faint">Field Sales console · Sandbox {APP_ID}</p>
</div>
<form onSubmit={submit} className="flex flex-col gap-4">
<Input label="Email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} required autoFocus />
<Input label="Password" type="password" value={password} onChange={(e) => setPassword(e.target.value)} required />
<Input label="Org ID" hint="Optional" value={orgId} onChange={(e) => setOrgId(e.target.value)} />
{error && <div className="text-xs text-ruby-600 font-medium">{error}</div>}
<Button type="submit" full disabled={busy}>
{busy ? 'Signing in…' : 'Sign in'}
</Button>
</form>
</Card>
</div>
);
}

View File

@ -0,0 +1,33 @@
import { Navigate, useNavigate, useParams } from 'react-router-dom';
import { Modal } from '../components/reusable';
import { tabByKey } from './tabs';
/** Record view for /:tab, with a deep-linkable detail modal at /:tab/:instanceId. */
export function WorkflowPage() {
const { tab, instanceId } = useParams();
const navigate = useNavigate();
const def = tabByKey(tab);
// Unknown tab → default to orders.
if (!def) return <Navigate to="/orders" replace />;
const { View, Detail, noun, key } = def;
return (
<>
<View onRowClick={(row) => {
const id = row.instance_id as number | string | undefined;
if (id != null) navigate(`/${key}/${id}`);
}} />
<Modal
open={instanceId != null}
onClose={() => navigate(`/${key}`)}
title={instanceId != null ? `${noun} #${instanceId}` : undefined}
width="lg"
>
{instanceId != null && <Detail instanceId={instanceId} />}
</Modal>
</>
);
}

38
src/screens/tabs.ts Normal file
View File

@ -0,0 +1,38 @@
import { Store, Phone, ShoppingCart, ClipboardList, type LucideIcon } from 'lucide-react';
import {
OrdersView,
CallsView,
StoresView,
DailyLogsView,
type WiredRecordViewProps,
} from '../components/rv';
import {
OrderDetail,
CallDetail,
StoreDetail,
DailyLogDetail,
type WiredDetailViewProps,
} from '../components/dv';
export type TabKey = 'orders' | 'calls' | 'stores' | 'daily';
export interface TabDef {
key: TabKey;
label: string;
icon: LucideIcon;
View: (p: WiredRecordViewProps) => React.JSX.Element;
Detail: (p: WiredDetailViewProps) => React.JSX.Element;
/** Singular noun for the detail title. */
noun: string;
}
export const TABS: TabDef[] = [
{ key: 'orders', label: 'Orders', icon: ShoppingCart, View: OrdersView, Detail: OrderDetail, noun: 'Order' },
{ key: 'calls', label: 'Calls', icon: Phone, View: CallsView, Detail: CallDetail, noun: 'Call' },
{ key: 'stores', label: 'Stores', icon: Store, View: StoresView, Detail: StoreDetail, noun: 'Store' },
{ key: 'daily', label: 'Daily Logs', icon: ClipboardList, View: DailyLogsView, Detail: DailyLogDetail, noun: 'Daily Log' },
];
export function tabByKey(key: string | undefined): TabDef | undefined {
return TABS.find((t) => t.key === key);
}

177
src/styles/styles.css Normal file
View File

@ -0,0 +1,177 @@
/* ============================================================
KRISHNA SALES global stylesheet
Tailwind v4 + design tokens. The @theme inline block wires
Tailwind's utility namespaces to the CSS-variable tokens, so
`bg-navy-900`, `rounded-lg`, `shadow-md`, `font-numeric` etc.
resolve to the design system with zero duplication.
(Referenced from the lead-to-policy console.)
============================================================ */
@import url('https://fonts.googleapis.com/css2?family=Inter:opsz,wght@14..32,400;14..32,500;14..32,600;14..32,700;14..32,800&family=Inter+Tight:wght@500;600;700;800&display=swap');
@import 'tailwindcss';
@import './tokens/colors.css';
@import './tokens/typography.css';
@import './tokens/spacing.css';
@theme inline {
/* ---- Brand: Navy ---- */
--color-navy-950: var(--navy-950);
--color-navy-900: var(--navy-900);
--color-navy-800: var(--navy-800);
--color-navy-700: var(--navy-700);
--color-navy-600: var(--navy-600);
--color-navy-500: var(--navy-500);
--color-navy-400: var(--navy-400);
/* ---- Brand: Sunrise ---- */
--color-sunrise-600: var(--sunrise-600);
--color-sunrise-500: var(--sunrise-500);
--color-sunrise-400: var(--sunrise-400);
--color-sunrise-300: var(--sunrise-300);
--color-sunrise-200: var(--sunrise-200);
--color-sunrise-100: var(--sunrise-100);
/* ---- Semantic: Emerald / Amber / Ruby / Sky ---- */
--color-emerald-700: var(--emerald-700);
--color-emerald-600: var(--emerald-600);
--color-emerald-500: var(--emerald-500);
--color-emerald-300: var(--emerald-300);
--color-emerald-100: var(--emerald-100);
--color-amber-700: var(--amber-700);
--color-amber-600: var(--amber-600);
--color-amber-500: var(--amber-500);
--color-amber-300: var(--amber-300);
--color-amber-100: var(--amber-100);
--color-ruby-700: var(--ruby-700);
--color-ruby-600: var(--ruby-600);
--color-ruby-500: var(--ruby-500);
--color-ruby-100: var(--ruby-100);
--color-sky-700: var(--sky-700);
--color-sky-600: var(--sky-600);
--color-sky-500: var(--sky-500);
--color-sky-100: var(--sky-100);
/* ---- Neutral: Slate ---- */
--color-slate-950: var(--slate-950);
--color-slate-900: var(--slate-900);
--color-slate-800: var(--slate-800);
--color-slate-700: var(--slate-700);
--color-slate-600: var(--slate-600);
--color-slate-500: var(--slate-500);
--color-slate-400: var(--slate-400);
--color-slate-300: var(--slate-300);
--color-slate-200: var(--slate-200);
--color-slate-150: var(--slate-150);
--color-slate-100: var(--slate-100);
--color-slate-50: var(--slate-50);
/* ---- Semantic aliases (short, readable utility names) ---- */
--color-app: var(--surface-app);
--color-card: var(--surface-card);
--color-sunk: var(--surface-sunk);
--color-strong: var(--text-strong);
--color-body: var(--text-body);
--color-muted: var(--text-muted);
--color-faint: var(--text-faint);
--color-on-navy: var(--text-on-navy);
--color-on-navy-muted: var(--text-on-navy-muted);
--color-accent: var(--text-accent);
--color-link: var(--text-link);
--color-border-subtle: var(--border-subtle);
--color-border-default: var(--border-default);
/* ---- Radii ---- */
--radius-xs: var(--radius-xs);
--radius-sm: var(--radius-sm);
--radius-md: var(--radius-md);
--radius-lg: var(--radius-lg);
--radius-xl: var(--radius-xl);
--radius-2xl: var(--radius-2xl);
--radius-pill: var(--radius-pill);
/* ---- Elevation ---- */
--shadow-xs: var(--shadow-xs);
--shadow-sm: var(--shadow-sm);
--shadow-md: var(--shadow-md);
--shadow-lg: var(--shadow-lg);
--shadow-xl: var(--shadow-xl);
--shadow-sunrise: var(--shadow-sunrise);
/* ---- Fonts ---- */
--font-sans: var(--font-sans);
--font-numeric: var(--font-numeric);
--font-mono: var(--font-mono);
/* ---- Type scale ---- */
--text-2xs: var(--text-2xs);
--text-xs: var(--text-xs);
--text-sm: var(--text-sm);
--text-base: var(--text-base);
--text-md: var(--text-md);
--text-lg: var(--text-lg);
--text-xl: var(--text-xl);
--text-2xl: var(--text-2xl);
--text-3xl: var(--text-3xl);
--text-4xl: var(--text-4xl);
--text-5xl: var(--text-5xl);
--text-6xl: var(--text-6xl);
}
@layer base {
html {
height: 100%;
}
body,
#root {
min-height: 100%;
}
body {
margin: 0;
font-family: var(--font-sans);
background: var(--surface-app);
color: var(--text-body);
-webkit-font-smoothing: antialiased;
}
}
@layer components {
/* Brand gradients — not expressible as plain utilities. */
.bg-sunrise {
background-image: var(--gradient-sunrise);
}
.bg-sunrise-soft {
background-image: var(--gradient-sunrise-soft);
}
.bg-navy-grad {
background-image: var(--gradient-navy);
}
/* Tabular figures for aligning currency & counts. */
.nums {
font-variant-numeric: tabular-nums;
}
/* Sunrise focus ring on inputs/selects. */
.focus-ring:focus-within {
border-color: var(--sunrise-500);
box-shadow: var(--shadow-focus);
}
/* Slim, theme-tinted scrollbar for in-panel scroll areas. */
.scrollbar-slim {
scrollbar-width: thin;
scrollbar-color: var(--border-default) transparent;
}
.scrollbar-slim::-webkit-scrollbar {
width: 10px;
height: 10px;
}
.scrollbar-slim::-webkit-scrollbar-track {
background: transparent;
}
.scrollbar-slim::-webkit-scrollbar-thumb {
background-color: var(--border-default);
background-clip: padding-box;
border: 3px solid transparent;
border-radius: 9999px;
}
.scrollbar-slim:hover::-webkit-scrollbar-thumb {
background-color: var(--text-faint);
}
}

View File

@ -0,0 +1,111 @@
/* ============================================================
KRISHNA SALES COLOR TOKENS
Field-sales console. Base deep navy, sunrise accent gradient,
emerald success, amber warning, slate neutrals.
(Design system referenced from the lead-to-policy console.)
============================================================ */
:root {
/* ---- Brand: Navy (primary surface + ink) ---- */
--navy-950: #060F24;
--navy-900: #0B1B3B; /* base deep navy */
--navy-800: #122549;
--navy-700: #1B3260;
--navy-600: #294780;
--navy-500: #3A5DA0;
--navy-400: #6987BF;
/* ---- Brand: Sunrise (accent gradient) ---- */
--sunrise-600: #E2552A;
--sunrise-500: #F26B3A; /* gradient start */
--sunrise-400: #FB8A4A;
--sunrise-300: #FBA94C; /* gradient end */
--sunrise-200: #FDD09A;
--sunrise-100: #FEF0E0;
--gradient-sunrise: linear-gradient(100deg, #F26B3A 0%, #FBA94C 100%);
--gradient-sunrise-soft: linear-gradient(135deg, #FEF0E0 0%, #FDE4D2 100%);
--gradient-navy: linear-gradient(160deg, #0B1B3B 0%, #122549 60%, #1B3260 100%);
/* ---- Semantic: Emerald (success) ---- */
--emerald-700: #0A7A52;
--emerald-600: #0E9466;
--emerald-500: #16B17C;
--emerald-300: #6FD7B0;
--emerald-100: #DBF5EB;
/* ---- Semantic: Amber (warning) ---- */
--amber-700: #B26B05;
--amber-600: #D98512;
--amber-500: #F0A529;
--amber-300: #FBCF7E;
--amber-100: #FCF1DB;
/* ---- Semantic: Ruby (error) ---- */
--ruby-700: #B4243B;
--ruby-600: #D63A52;
--ruby-500: #E85A70;
--ruby-100: #FBE2E6;
/* ---- Semantic: Sky (info) ---- */
--sky-700: #1565B8;
--sky-600: #2480DB;
--sky-500: #4AA0EF;
--sky-100: #DCEEFC;
/* ---- Neutral: Slate ---- */
--slate-950: #0E1726;
--slate-900: #1B2638;
--slate-800: #2E3B52;
--slate-700: #45536E;
--slate-600: #5E6E8C;
--slate-500: #7C8AA6;
--slate-400: #9DA9C2;
--slate-300: #C2CADB;
--slate-200: #DDE3EE;
--slate-150: #E8ECF4;
--slate-100: #F0F3F8;
--slate-50: #F7F9FC;
--white: #FFFFFF;
/* ---- Semantic aliases (light mode) ---- */
/* Surfaces */
--surface-app: var(--slate-50);
--surface-card: var(--white);
--surface-sunk: var(--slate-100);
--surface-raised: var(--white);
--surface-navy: var(--navy-900);
--surface-navy-soft: var(--navy-800);
/* Text */
--text-strong: var(--navy-900);
--text-body: var(--slate-800);
--text-muted: var(--slate-600);
--text-faint: var(--slate-500);
--text-on-navy: #EAF0FA;
--text-on-navy-muted: #9DB0D2;
--text-on-accent: #FFFFFF;
--text-accent: var(--sunrise-600);
--text-link: var(--sky-700);
/* Borders / hairlines */
--border-subtle: var(--slate-200);
--border-default: var(--slate-300);
--border-strong: var(--slate-400);
--border-navy: rgba(255,255,255,0.12);
/* Brand action */
--action-primary: var(--sunrise-500);
--action-primary-hover: var(--sunrise-600);
--action-navy: var(--navy-900);
--action-navy-hover: var(--navy-800);
--focus-ring: rgba(242,107,58,0.45);
/* Status */
--status-success: var(--emerald-600);
--status-success-soft: var(--emerald-100);
--status-warning: var(--amber-600);
--status-warning-soft: var(--amber-100);
--status-error: var(--ruby-600);
--status-error-soft: var(--ruby-100);
--status-info: var(--sky-600);
--status-info-soft: var(--sky-100);
}

View File

@ -0,0 +1,25 @@
/* ============================================================
KRISHNA SALES RADII & ELEVATION
============================================================ */
:root {
/* ---- Corner radii ---- */
--radius-xs: 6px;
--radius-sm: 8px;
--radius-md: 12px; /* default control radius */
--radius-lg: 16px; /* default card radius */
--radius-xl: 20px;
--radius-2xl: 28px;
--radius-pill: 999px;
/* ---- Soft elevation (navy-tinted, low spread) ---- */
--shadow-xs: 0 1px 2px rgba(11,27,59,0.06);
--shadow-sm: 0 1px 3px rgba(11,27,59,0.08), 0 1px 2px rgba(11,27,59,0.04);
--shadow-md: 0 4px 12px rgba(11,27,59,0.08), 0 2px 4px rgba(11,27,59,0.04);
--shadow-lg: 0 12px 28px rgba(11,27,59,0.12), 0 4px 8px rgba(11,27,59,0.06);
--shadow-xl: 0 24px 56px rgba(11,27,59,0.18), 0 8px 16px rgba(11,27,59,0.08);
--shadow-focus: 0 0 0 3px var(--focus-ring);
--shadow-sunrise: 0 8px 24px rgba(242,107,58,0.22);
/* ---- Layout ---- */
--container-max: 1280px;
}

View File

@ -0,0 +1,30 @@
/* ============================================================
KRISHNA SALES TYPOGRAPHY TOKENS
Inter for UI & body. Inter Tight for oversized numerals.
============================================================ */
:root {
--font-sans: 'Inter', system-ui, -apple-system, 'Segoe UI', sans-serif;
--font-numeric: 'Inter Tight', 'Inter', system-ui, sans-serif;
--font-mono: 'SF Mono', ui-monospace, 'Menlo', monospace;
/* ---- Type scale (px) ---- */
--text-2xs: 11px;
--text-xs: 12px;
--text-sm: 13px;
--text-base: 14px;
--text-md: 15px;
--text-lg: 17px;
--text-xl: 20px;
--text-2xl: 24px;
--text-3xl: 30px;
--text-4xl: 38px;
--text-5xl: 48px;
--text-6xl: 64px;
/* ---- Weights ---- */
--weight-regular: 400;
--weight-medium: 500;
--weight-semibold: 600;
--weight-bold: 700;
--weight-extrabold: 800;
}

View File

@ -1,7 +1,8 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react(), tailwindcss()],
}) })