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:
parent
3f36c92075
commit
d5e4ebee5d
32
CLAUDE.md
Normal file
32
CLAUDE.md
Normal 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
125
README.md
@ -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)
|
||||
- [@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...
|
||||
},
|
||||
},
|
||||
])
|
||||
## Getting started
|
||||
|
||||
```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
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
Other scripts:
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```bash
|
||||
npm run build # tsc -b && vite build → dist/
|
||||
npm run preview # serve the production build
|
||||
npm run lint # eslint
|
||||
```
|
||||
|
||||
## Workflows (app 434)
|
||||
|
||||
The app has three workflows, each with its own `workflow_uuid` (shared `APP_ID` 434). One `ZinoClient` is bound per workflow.
|
||||
|
||||
| Slug (`config.ts`) | UI | Record views | Detail views |
|
||||
| --- | --- | --- | --- |
|
||||
| `orderBooking` | Order Booking | Orders, Calls | Orders, Calls |
|
||||
| `store` | Store | Store | Store |
|
||||
| `dailyReports` | Daily Reports | Daily Logs | Daily Logs |
|
||||
|
||||
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
406
package-lock.json
generated
@ -8,11 +8,14 @@
|
||||
"name": "frontend",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"lucide-react": "^0.400.0",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7"
|
||||
"react-dom": "^19.2.7",
|
||||
"react-router-dom": "^7.18.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@types/node": "^24.13.2",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
@ -21,6 +24,7 @@
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.3",
|
||||
"globals": "^17.7.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "~6.0.2",
|
||||
"typescript-eslint": "^8.62.0",
|
||||
"vite": "^8.1.1"
|
||||
@ -837,6 +841,278 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "0.10.3",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
|
||||
@ -1306,6 +1582,19 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@ -1370,6 +1659,20 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||
@ -1719,6 +2022,13 @@
|
||||
"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": {
|
||||
"version": "0.25.1",
|
||||
"resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
|
||||
@ -1786,6 +2096,16 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
@ -2151,6 +2471,25 @@
|
||||
"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": {
|
||||
"version": "10.2.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
|
||||
@ -2370,6 +2709,44 @@
|
||||
"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": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz",
|
||||
@ -2420,6 +2797,12 @@
|
||||
"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": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
@ -2453,6 +2836,27 @@
|
||||
"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": {
|
||||
"version": "0.2.17",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||
|
||||
@ -10,11 +10,14 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"lucide-react": "^0.400.0",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7"
|
||||
"react-dom": "^19.2.7",
|
||||
"react-router-dom": "^7.18.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@types/node": "^24.13.2",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
@ -23,6 +26,7 @@
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.3",
|
||||
"globals": "^17.7.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "~6.0.2",
|
||||
"typescript-eslint": "^8.62.0",
|
||||
"vite": "^8.1.1"
|
||||
|
||||
132
src/App.tsx
132
src/App.tsx
@ -1,121 +1,23 @@
|
||||
import { useState } from 'react'
|
||||
import reactLogo from './assets/react.svg'
|
||||
import viteLogo from './assets/vite.svg'
|
||||
import heroImg from './assets/hero.png'
|
||||
import './App.css'
|
||||
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
|
||||
import { AuthProvider } from './auth/AuthProvider'
|
||||
import { LoginPage } from './screens/LoginPage'
|
||||
import { ConsoleLayout } from './screens/ConsoleLayout'
|
||||
import { WorkflowPage } from './screens/WorkflowPage'
|
||||
|
||||
function App() {
|
||||
const [count, setCount] = useState(0)
|
||||
|
||||
return (
|
||||
<>
|
||||
<section id="center">
|
||||
<div className="hero">
|
||||
<img src={heroImg} className="base" width="170" height="179" alt="" />
|
||||
<img src={reactLogo} className="framework" alt="React logo" />
|
||||
<img src={viteLogo} className="vite" alt="Vite logo" />
|
||||
</div>
|
||||
<div>
|
||||
<h1>Get started</h1>
|
||||
<p>
|
||||
Edit <code>src/App.tsx</code> and save to test <code>HMR</code>
|
||||
</p>
|
||||
</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>
|
||||
</>
|
||||
<AuthProvider>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route element={<ConsoleLayout />}>
|
||||
<Route path="/:tab" element={<WorkflowPage />} />
|
||||
<Route path="/:tab/:instanceId" element={<WorkflowPage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/orders" replace />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</AuthProvider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
287
src/api/client.ts
Normal file
287
src/api/client.ts
Normal 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
49
src/api/clients.ts
Normal 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
167
src/api/config.ts
Normal 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
191
src/api/types.ts
Normal 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
21
src/auth/AuthProvider.tsx
Normal 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
15
src/auth/context.ts
Normal 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;
|
||||
}
|
||||
65
src/components/buttons/Button.tsx
Normal file
65
src/components/buttons/Button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
src/components/buttons/IconButton.tsx
Normal file
36
src/components/buttons/IconButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
4
src/components/buttons/index.ts
Normal file
4
src/components/buttons/index.ts
Normal 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';
|
||||
17
src/components/dv/CallDetail.tsx
Normal file
17
src/components/dv/CallDetail.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
17
src/components/dv/DailyLogDetail.tsx
Normal file
17
src/components/dv/DailyLogDetail.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
97
src/components/dv/DetailView.tsx
Normal file
97
src/components/dv/DetailView.tsx
Normal 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="Couldn’t 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>
|
||||
);
|
||||
}
|
||||
21
src/components/dv/OrderDetail.tsx
Normal file
21
src/components/dv/OrderDetail.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
17
src/components/dv/StoreDetail.tsx
Normal file
17
src/components/dv/StoreDetail.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
7
src/components/dv/index.ts
Normal file
7
src/components/dv/index.ts
Normal 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
6
src/components/index.ts
Normal 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';
|
||||
51
src/components/reusable/Badge.tsx
Normal file
51
src/components/reusable/Badge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
58
src/components/reusable/Card.tsx
Normal file
58
src/components/reusable/Card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
24
src/components/reusable/EmptyState.tsx
Normal file
24
src/components/reusable/EmptyState.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
60
src/components/reusable/Input.tsx
Normal file
60
src/components/reusable/Input.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
77
src/components/reusable/Modal.tsx
Normal file
77
src/components/reusable/Modal.tsx
Normal 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,
|
||||
);
|
||||
}
|
||||
41
src/components/reusable/Pagination.tsx
Normal file
41
src/components/reusable/Pagination.tsx
Normal 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 a–b 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>
|
||||
);
|
||||
}
|
||||
50
src/components/reusable/Select.tsx
Normal file
50
src/components/reusable/Select.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
src/components/reusable/Spinner.tsx
Normal file
20
src/components/reusable/Spinner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
src/components/reusable/index.ts
Normal file
16
src/components/reusable/index.ts
Normal 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';
|
||||
17
src/components/rv/CallsView.tsx
Normal file
17
src/components/rv/CallsView.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
17
src/components/rv/DailyLogsView.tsx
Normal file
17
src/components/rv/DailyLogsView.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
21
src/components/rv/OrdersView.tsx
Normal file
21
src/components/rv/OrdersView.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
158
src/components/rv/RecordView.tsx
Normal file
158
src/components/rv/RecordView.tsx
Normal 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="Couldn’t 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>
|
||||
);
|
||||
}
|
||||
17
src/components/rv/StoresView.tsx
Normal file
17
src/components/rv/StoresView.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
7
src/components/rv/index.ts
Normal file
7
src/components/rv/index.ts
Normal 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';
|
||||
363
src/docs/api/daily_reports.md
Normal file
363
src/docs/api/daily_reports.md
Normal 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 | |
|
||||
456
src/docs/api/order_booking.md
Normal file
456
src/docs/api/order_booking.md
Normal 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
386
src/docs/api/store.md
Normal 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
4
src/lib/cn.ts
Normal 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
50
src/lib/format.ts
Normal 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);
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import './styles/styles.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
|
||||
76
src/screens/ConsoleLayout.tsx
Normal file
76
src/screens/ConsoleLayout.tsx
Normal 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
53
src/screens/LoginPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
src/screens/WorkflowPage.tsx
Normal file
33
src/screens/WorkflowPage.tsx
Normal 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
38
src/screens/tabs.ts
Normal 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
177
src/styles/styles.css
Normal 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);
|
||||
}
|
||||
}
|
||||
111
src/styles/tokens/colors.css
Normal file
111
src/styles/tokens/colors.css
Normal 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);
|
||||
}
|
||||
25
src/styles/tokens/spacing.css
Normal file
25
src/styles/tokens/spacing.css
Normal 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;
|
||||
}
|
||||
30
src/styles/tokens/typography.css
Normal file
30
src/styles/tokens/typography.css
Normal 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;
|
||||
}
|
||||
@ -1,7 +1,8 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
plugins: [react(), tailwindcss()],
|
||||
})
|
||||
|
||||
Loading…
Reference in New Issue
Block a user