From d5e4ebee5d4f58e50f716f6a51b838e12b922fde Mon Sep 17 00:00:00 2001 From: Bhanu Prakash Sai Potteri Date: Mon, 6 Jul 2026 15:16:34 +0530 Subject: [PATCH] Build app-434 field-sales console MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CLAUDE.md | 32 ++ README.md | 125 ++++--- package-lock.json | 406 +++++++++++++++++++++- package.json | 6 +- src/App.tsx | 132 +------ src/api/client.ts | 287 ++++++++++++++++ src/api/clients.ts | 49 +++ src/api/config.ts | 167 +++++++++ src/api/types.ts | 191 +++++++++++ src/auth/AuthProvider.tsx | 21 ++ src/auth/context.ts | 15 + src/components/buttons/Button.tsx | 65 ++++ src/components/buttons/IconButton.tsx | 36 ++ src/components/buttons/index.ts | 4 + src/components/dv/CallDetail.tsx | 17 + src/components/dv/DailyLogDetail.tsx | 17 + src/components/dv/DetailView.tsx | 97 ++++++ src/components/dv/OrderDetail.tsx | 21 ++ src/components/dv/StoreDetail.tsx | 17 + src/components/dv/index.ts | 7 + src/components/index.ts | 6 + src/components/reusable/Badge.tsx | 51 +++ src/components/reusable/Card.tsx | 58 ++++ src/components/reusable/EmptyState.tsx | 24 ++ src/components/reusable/Input.tsx | 60 ++++ src/components/reusable/Modal.tsx | 77 +++++ src/components/reusable/Pagination.tsx | 41 +++ src/components/reusable/Select.tsx | 50 +++ src/components/reusable/Spinner.tsx | 20 ++ src/components/reusable/index.ts | 16 + src/components/rv/CallsView.tsx | 17 + src/components/rv/DailyLogsView.tsx | 17 + src/components/rv/OrdersView.tsx | 21 ++ src/components/rv/RecordView.tsx | 158 +++++++++ src/components/rv/StoresView.tsx | 17 + src/components/rv/index.ts | 7 + src/docs/api/daily_reports.md | 363 ++++++++++++++++++++ src/docs/api/order_booking.md | 456 +++++++++++++++++++++++++ src/docs/api/store.md | 386 +++++++++++++++++++++ src/lib/cn.ts | 4 + src/lib/format.ts | 50 +++ src/main.tsx | 2 +- src/screens/ConsoleLayout.tsx | 76 +++++ src/screens/LoginPage.tsx | 53 +++ src/screens/WorkflowPage.tsx | 33 ++ src/screens/tabs.ts | 38 +++ src/styles/styles.css | 177 ++++++++++ src/styles/tokens/colors.css | 111 ++++++ src/styles/tokens/spacing.css | 25 ++ src/styles/tokens/typography.css | 30 ++ vite.config.ts | 3 +- 51 files changed, 3975 insertions(+), 184 deletions(-) create mode 100644 CLAUDE.md create mode 100644 src/api/client.ts create mode 100644 src/api/clients.ts create mode 100644 src/api/config.ts create mode 100644 src/api/types.ts create mode 100644 src/auth/AuthProvider.tsx create mode 100644 src/auth/context.ts create mode 100644 src/components/buttons/Button.tsx create mode 100644 src/components/buttons/IconButton.tsx create mode 100644 src/components/buttons/index.ts create mode 100644 src/components/dv/CallDetail.tsx create mode 100644 src/components/dv/DailyLogDetail.tsx create mode 100644 src/components/dv/DetailView.tsx create mode 100644 src/components/dv/OrderDetail.tsx create mode 100644 src/components/dv/StoreDetail.tsx create mode 100644 src/components/dv/index.ts create mode 100644 src/components/index.ts create mode 100644 src/components/reusable/Badge.tsx create mode 100644 src/components/reusable/Card.tsx create mode 100644 src/components/reusable/EmptyState.tsx create mode 100644 src/components/reusable/Input.tsx create mode 100644 src/components/reusable/Modal.tsx create mode 100644 src/components/reusable/Pagination.tsx create mode 100644 src/components/reusable/Select.tsx create mode 100644 src/components/reusable/Spinner.tsx create mode 100644 src/components/reusable/index.ts create mode 100644 src/components/rv/CallsView.tsx create mode 100644 src/components/rv/DailyLogsView.tsx create mode 100644 src/components/rv/OrdersView.tsx create mode 100644 src/components/rv/RecordView.tsx create mode 100644 src/components/rv/StoresView.tsx create mode 100644 src/components/rv/index.ts create mode 100644 src/docs/api/daily_reports.md create mode 100644 src/docs/api/order_booking.md create mode 100644 src/docs/api/store.md create mode 100644 src/lib/cn.ts create mode 100644 src/lib/format.ts create mode 100644 src/screens/ConsoleLayout.tsx create mode 100644 src/screens/LoginPage.tsx create mode 100644 src/screens/WorkflowPage.tsx create mode 100644 src/screens/tabs.ts create mode 100644 src/styles/styles.css create mode 100644 src/styles/tokens/colors.css create mode 100644 src/styles/tokens/spacing.css create mode 100644 src/styles/tokens/typography.css diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..8050e69 --- /dev/null +++ b/CLAUDE.md @@ -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..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/`. diff --git a/README.md b/README.md index c300135..38c4031 100644 --- a/README.md +++ b/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. diff --git a/package-lock.json b/package-lock.json index abed464..076f055 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 021c5c9..cc1000f 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/src/App.tsx b/src/App.tsx index a66b5ef..fe5cf2c 100644 --- a/src/App.tsx +++ b/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 ( - <> -
-
- - React logo - Vite logo -
-
-

Get started

-

- Edit src/App.tsx and save to test HMR -

-
- -
- -
- -
-
- -

Documentation

-

Your questions, answered

- -
-
- -

Connect with us

-

Join the Vite community

- -
-
- -
-
- + + + + } /> + }> + } /> + } /> + + } /> + + + ) } diff --git a/src/api/client.ts b/src/api/client.ts new file mode 100644 index 0000000..a298628 --- /dev/null +++ b/src/api/client.ts @@ -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(method: string, path: string, body?: unknown): Promise { + const headers: Record = { 'Content-Type': 'application/json' }; + if (this.token) headers['Authorization'] = `Bearer ${this.token}`; + + return this.send(method, path, { + headers, + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + } + + // Multipart variant — no Content-Type header (the browser sets the boundary). + private async requestForm(path: string, form: FormData): Promise { + const headers: Record = {}; + if (this.token) headers['Authorization'] = `Bearer ${this.token}`; + return this.send('POST', path, { headers, body: form }); + } + + private async send(method: string, path: string, init: RequestInit): Promise { + 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 { + const res = await this.request('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 { + return this.request('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; + }> { + return this.request( + 'GET', + `/app/${APP_ID}/view/detailview/${dvUid}?instance_id=${encodeURIComponent(String(instanceId))}`, + ); + } + + audit(instanceId: number | string): Promise { + return this.request( + '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 { + 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 { + return this.request('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): Promise { + return this.request('POST', `/app/${APP_ID}/start`, { + workflow_uuid: this.workflowUuid, + activity_id: activityUid, + data, + }); + } + + performActivity( + instanceId: number | string, + activityUid: string, + data: Record, + ): Promise { + return this.request('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; + }, + ): Promise<{ records: Array> }> { + 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; + }): Promise<{ options: Array<{ label: string; value: string; _raw?: Record }> }> { + 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> { + 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; 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; +} diff --git a/src/api/clients.ts b/src/api/clients.ts new file mode 100644 index 0000000..a60c8c7 --- /dev/null +++ b/src/api/clients.ts @@ -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(); + +/** 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)); +} diff --git a/src/api/config.ts b/src/api/config.ts new file mode 100644 index 0000000..ea5cadd --- /dev/null +++ b/src/api/config.ts @@ -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..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; diff --git a/src/api/types.ts b/src/api/types.ts new file mode 100644 index 0000000..8105dc2 --- /dev/null +++ b/src/api/types.ts @@ -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[]; + 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; +} + +// --- Audit (GET /app/{appId}/view/audit) --- + +export interface AuditEntry { + id: number; + user_id: string; + user_roles: string[]; + activity_id: string; + data: Record; + 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; + created_at: string; +} + +// --- Workflow execution (core) --- + +export interface ActivityResult { + success?: boolean; + instance_id?: number | string; + data?: Record; + 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; +} + diff --git a/src/auth/AuthProvider.tsx b/src/auth/AuthProvider.tsx new file mode 100644 index 0000000..5c59eab --- /dev/null +++ b/src/auth/AuthProvider.tsx @@ -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 {children}; +} diff --git a/src/auth/context.ts b/src/auth/context.ts new file mode 100644 index 0000000..5dc72b7 --- /dev/null +++ b/src/auth/context.ts @@ -0,0 +1,15 @@ +import { createContext, useContext } from 'react'; + +export interface AuthValue { + authed: boolean; + login: (email: string, password: string, orgId?: string) => Promise; + logout: () => void; +} + +export const AuthCtx = createContext(null); + +export function useAuth(): AuthValue { + const v = useContext(AuthCtx); + if (!v) throw new Error('useAuth must be used within '); + return v; +} diff --git a/src/components/buttons/Button.tsx b/src/components/buttons/Button.tsx new file mode 100644 index 0000000..a7fda27 --- /dev/null +++ b/src/components/buttons/Button.tsx @@ -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 { + /** 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 = { + 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 = { + 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 ( + + ); +} diff --git a/src/components/buttons/IconButton.tsx b/src/components/buttons/IconButton.tsx new file mode 100644 index 0000000..f1c7356 --- /dev/null +++ b/src/components/buttons/IconButton.tsx @@ -0,0 +1,36 @@ +import type { ButtonHTMLAttributes, ReactNode } from 'react'; +import { cn } from '../../lib/cn'; + +export interface IconButtonProps extends ButtonHTMLAttributes { + /** 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 ( + + ); +} diff --git a/src/components/buttons/index.ts b/src/components/buttons/index.ts new file mode 100644 index 0000000..9335a19 --- /dev/null +++ b/src/components/buttons/index.ts @@ -0,0 +1,4 @@ +export { Button } from './Button'; +export type { ButtonProps, ButtonVariant, ButtonSize } from './Button'; +export { IconButton } from './IconButton'; +export type { IconButtonProps } from './IconButton'; diff --git a/src/components/dv/CallDetail.tsx b/src/components/dv/CallDetail.tsx new file mode 100644 index 0000000..0fe0b75 --- /dev/null +++ b/src/components/dv/CallDetail.tsx @@ -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 ( + + ); +} diff --git a/src/components/dv/DailyLogDetail.tsx b/src/components/dv/DailyLogDetail.tsx new file mode 100644 index 0000000..2de54bd --- /dev/null +++ b/src/components/dv/DailyLogDetail.tsx @@ -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 ( + + ); +} diff --git a/src/components/dv/DetailView.tsx b/src/components/dv/DetailView.tsx new file mode 100644 index 0000000..3b7849c --- /dev/null +++ b/src/components/dv/DetailView.tsx @@ -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; +} + +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(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( + + {error ? ( + + ) : loading ? ( +
+ +
+ ) : shown.length === 0 ? ( + + ) : ( +
+ {shown.map((f) => ( +
+
{f.output_label}
+
+ {formatValue(resp?.data[f.field_key])} +
+
+ ))} +
+ )} +
+ ); +} diff --git a/src/components/dv/OrderDetail.tsx b/src/components/dv/OrderDetail.tsx new file mode 100644 index 0000000..9d8b1ce --- /dev/null +++ b/src/components/dv/OrderDetail.tsx @@ -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 ( + + ); +} diff --git a/src/components/dv/StoreDetail.tsx b/src/components/dv/StoreDetail.tsx new file mode 100644 index 0000000..9d2b109 --- /dev/null +++ b/src/components/dv/StoreDetail.tsx @@ -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 ( + + ); +} diff --git a/src/components/dv/index.ts b/src/components/dv/index.ts new file mode 100644 index 0000000..f146b7d --- /dev/null +++ b/src/components/dv/index.ts @@ -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'; diff --git a/src/components/index.ts b/src/components/index.ts new file mode 100644 index 0000000..1b4d7af --- /dev/null +++ b/src/components/index.ts @@ -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'; diff --git a/src/components/reusable/Badge.tsx b/src/components/reusable/Badge.tsx new file mode 100644 index 0000000..7af90d9 --- /dev/null +++ b/src/components/reusable/Badge.tsx @@ -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 = { + 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 ( + + {dot && } + {children} + + ); +} diff --git a/src/components/reusable/Card.tsx b/src/components/reusable/Card.tsx new file mode 100644 index 0000000..6f29d67 --- /dev/null +++ b/src/components/reusable/Card.tsx @@ -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 = { + 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 ( +
+ {title && ( +
+

{title}

+ {action} +
+ )} +
{children}
+
+ ); +} diff --git a/src/components/reusable/EmptyState.tsx b/src/components/reusable/EmptyState.tsx new file mode 100644 index 0000000..13f7ee5 --- /dev/null +++ b/src/components/reusable/EmptyState.tsx @@ -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 ( +
+ {icon &&
{icon}
} +
{title}
+ {hint &&
{hint}
} + {action &&
{action}
} +
+ ); +} diff --git a/src/components/reusable/Input.tsx b/src/components/reusable/Input.tsx new file mode 100644 index 0000000..a8a87ef --- /dev/null +++ b/src/components/reusable/Input.tsx @@ -0,0 +1,60 @@ +import type { InputHTMLAttributes, ReactNode } from 'react'; +import { cn } from '../../lib/cn'; + +export interface InputProps extends Omit, '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 ( + + ); +} diff --git a/src/components/reusable/Modal.tsx b/src/components/reusable/Modal.tsx new file mode 100644 index 0000000..00b34c5 --- /dev/null +++ b/src/components/reusable/Modal.tsx @@ -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 = { + 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( +
{ + if (e.target === e.currentTarget) onClose(); + }} + > +
+
+
+ {title &&

{title}

} + {subtitle &&
{subtitle}
} +
+ +
+
{children}
+
+
, + document.body, + ); +} diff --git a/src/components/reusable/Pagination.tsx b/src/components/reusable/Pagination.tsx new file mode 100644 index 0000000..712e8bb --- /dev/null +++ b/src/components/reusable/Pagination.tsx @@ -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 ( +
+ + Showing {start + 1}–{Math.min(start + pageSize, total)} of {total} + +
+ + + {safePage} / {totalPages} + + +
+
+ ); +} diff --git a/src/components/reusable/Select.tsx b/src/components/reusable/Select.tsx new file mode 100644 index 0000000..ca8921e --- /dev/null +++ b/src/components/reusable/Select.tsx @@ -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 { + label?: string; + hint?: string; + /** Either strings or {value,label} objects. */ + options?: Array; + /** 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 ( + + ); +} diff --git a/src/components/reusable/Spinner.tsx b/src/components/reusable/Spinner.tsx new file mode 100644 index 0000000..4a910a0 --- /dev/null +++ b/src/components/reusable/Spinner.tsx @@ -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 ( + + + {label && {label}} + + ); +} diff --git a/src/components/reusable/index.ts b/src/components/reusable/index.ts new file mode 100644 index 0000000..572ce8a --- /dev/null +++ b/src/components/reusable/index.ts @@ -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'; diff --git a/src/components/rv/CallsView.tsx b/src/components/rv/CallsView.tsx new file mode 100644 index 0000000..c4ec3e0 --- /dev/null +++ b/src/components/rv/CallsView.tsx @@ -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 ( + + ); +} diff --git a/src/components/rv/DailyLogsView.tsx b/src/components/rv/DailyLogsView.tsx new file mode 100644 index 0000000..27c2a8f --- /dev/null +++ b/src/components/rv/DailyLogsView.tsx @@ -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 ( + + ); +} diff --git a/src/components/rv/OrdersView.tsx b/src/components/rv/OrdersView.tsx new file mode 100644 index 0000000..75895c0 --- /dev/null +++ b/src/components/rv/OrdersView.tsx @@ -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, index: number) => void; + pageSize?: number; +} + +/** Orders record view (Order Booking workflow). */ +export function OrdersView({ onRowClick, pageSize }: WiredRecordViewProps) { + return ( + + ); +} diff --git a/src/components/rv/RecordView.tsx b/src/components/rv/RecordView.tsx new file mode 100644 index 0000000..7d1a8ed --- /dev/null +++ b/src/components/rv/RecordView.tsx @@ -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, index: number) => void; + /** Extract a stable key for a row. @default row.instance_id ?? index */ + rowKey?: (row: Record, 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(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // Debounce the search box; reset to page 1 whenever the term changes. + const timer = useRef | 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 ( + + + 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" + /> + + } + > + {error ? ( + + ) : loading && !resp ? ( +
+ +
+ ) : rows.length === 0 ? ( + + ) : ( +
+ + + + {fields.map((f) => ( + + ))} + + + + {rows.map((row, i) => ( + 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) => ( + + ))} + + ))} + +
+ {f.output_label} +
+ {formatValue(row[f.field_key])} +
+
+ )} + +
+ ); +} diff --git a/src/components/rv/StoresView.tsx b/src/components/rv/StoresView.tsx new file mode 100644 index 0000000..4d9d073 --- /dev/null +++ b/src/components/rv/StoresView.tsx @@ -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 ( + + ); +} diff --git a/src/components/rv/index.ts b/src/components/rv/index.ts new file mode 100644 index 0000000..00fca48 --- /dev/null +++ b/src/components/rv/index.ts @@ -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'; diff --git a/src/docs/api/daily_reports.md b/src/docs/api/daily_reports.md new file mode 100644 index 0000000..48fe178 --- /dev/null +++ b/src/docs/api/daily_reports.md @@ -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 ` (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": "", + "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": "", + "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": "" +} +``` + +| 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.}` 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=` | + +**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 | | diff --git a/src/docs/api/order_booking.md b/src/docs/api/order_booking.md new file mode 100644 index 0000000..f9197da --- /dev/null +++ b/src/docs/api/order_booking.md @@ -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 ` (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": "" + }, + "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": "", + "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": "", + "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": "", + "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": "", + "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": "" +} +``` + +| 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.}` 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=` | +| Calls | `/app/434/view/detailview/09a32bfe-9e43-4e0f-bb7d-db89c8a2557c?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=&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 | | diff --git a/src/docs/api/store.md b/src/docs/api/store.md new file mode 100644 index 0000000..184467c --- /dev/null +++ b/src/docs/api/store.md @@ -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 ` (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": "", + "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": "", + "store_location_3": "string_value", + "sub_route_3": "option_value" + }, + "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": "" +} +``` + +| 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.}` 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=` | + +**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 | | diff --git a/src/lib/cn.ts b/src/lib/cn.ts new file mode 100644 index 0000000..c88b3fd --- /dev/null +++ b/src/lib/cn.ts @@ -0,0 +1,4 @@ +/** Join truthy class names. Tiny helper — no dependency needed. */ +export function cn(...parts: Array): string { + return parts.filter(Boolean).join(' '); +} diff --git a/src/lib/format.ts b/src/lib/format.ts new file mode 100644 index 0000000..f8ae519 --- /dev/null +++ b/src/lib/format.ts @@ -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 { + 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; + // 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); +} diff --git a/src/main.tsx b/src/main.tsx index bef5202..57908d3 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -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( diff --git a/src/screens/ConsoleLayout.tsx b/src/screens/ConsoleLayout.tsx new file mode 100644 index 0000000..c4f0e9d --- /dev/null +++ b/src/screens/ConsoleLayout.tsx @@ -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 . */ +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 ; + + return ( +
+
+
+ Krishna Sales + Field Sales · Sandbox {APP_ID} +
+
+ {user?.name && {user.name}} + +
+
+ +
+ + + +
+
+ ); +} diff --git a/src/screens/LoginPage.tsx b/src/screens/LoginPage.tsx new file mode 100644 index 0000000..d5949cb --- /dev/null +++ b/src/screens/LoginPage.tsx @@ -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(null); + + if (authed) return ; + + 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 ( +
+ +
+

Krishna Sales

+

Field Sales console · Sandbox {APP_ID}

+
+
+ setEmail(e.target.value)} required autoFocus /> + setPassword(e.target.value)} required /> + setOrgId(e.target.value)} /> + {error &&
{error}
} + +
+
+
+ ); +} diff --git a/src/screens/WorkflowPage.tsx b/src/screens/WorkflowPage.tsx new file mode 100644 index 0000000..9e7e7ca --- /dev/null +++ b/src/screens/WorkflowPage.tsx @@ -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 ; + + const { View, Detail, noun, key } = def; + + return ( + <> + { + const id = row.instance_id as number | string | undefined; + if (id != null) navigate(`/${key}/${id}`); + }} /> + + navigate(`/${key}`)} + title={instanceId != null ? `${noun} #${instanceId}` : undefined} + width="lg" + > + {instanceId != null && } + + + ); +} diff --git a/src/screens/tabs.ts b/src/screens/tabs.ts new file mode 100644 index 0000000..7e7e035 --- /dev/null +++ b/src/screens/tabs.ts @@ -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); +} diff --git a/src/styles/styles.css b/src/styles/styles.css new file mode 100644 index 0000000..e3d6ef0 --- /dev/null +++ b/src/styles/styles.css @@ -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); + } +} diff --git a/src/styles/tokens/colors.css b/src/styles/tokens/colors.css new file mode 100644 index 0000000..8dc06b6 --- /dev/null +++ b/src/styles/tokens/colors.css @@ -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); +} diff --git a/src/styles/tokens/spacing.css b/src/styles/tokens/spacing.css new file mode 100644 index 0000000..db5e01d --- /dev/null +++ b/src/styles/tokens/spacing.css @@ -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; +} diff --git a/src/styles/tokens/typography.css b/src/styles/tokens/typography.css new file mode 100644 index 0000000..852fe5c --- /dev/null +++ b/src/styles/tokens/typography.css @@ -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; +} diff --git a/vite.config.ts b/vite.config.ts index 8b0f57b..c4069b7 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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()], })