Krishna Sales mobile PWA
Mobile PWA port of the desktop console: bottom-sheet modals, card-list record views, bottom tab nav, vite-plugin-pwa (manifest, service worker, icons). API layer, workflow UIDs, and design tokens reused verbatim.
27
.gitignore
vendored
Normal file
@ -0,0 +1,27 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# vite-plugin-pwa dev output
|
||||
dev-dist
|
||||
22
eslint.config.js
Normal file
@ -0,0 +1,22 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
19
index.html
Normal file
@ -0,0 +1,19 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/favicon.ico" sizes="48x48" />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#0B1B3B" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Krishna Sales" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon-180x180.png" />
|
||||
<title>Krishna Sales</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
8832
package-lock.json
generated
Normal file
38
package.json
Normal file
@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "krishna-sales-pwa",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"generate-pwa-assets": "pwa-assets-generator"
|
||||
},
|
||||
"dependencies": {
|
||||
"lucide-react": "^0.400.0",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-router-dom": "^7.18.1",
|
||||
"recharts": "^3.9.2"
|
||||
},
|
||||
"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",
|
||||
"@vite-pwa/assets-generator": "^1.0.2",
|
||||
"@vitejs/plugin-react": "^6.0.3",
|
||||
"eslint": "^10.6.0",
|
||||
"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",
|
||||
"vite-plugin-pwa": "^1.3.0"
|
||||
}
|
||||
}
|
||||
BIN
public/apple-touch-icon-180x180.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
public/favicon.ico
Normal file
|
After Width: | Height: | Size: 684 B |
10
public/favicon.svg
Normal file
@ -0,0 +1,10 @@
|
||||
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="sunrise" x1="128" y1="140" x2="384" y2="372" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#F26B3A"/>
|
||||
<stop offset="1" stop-color="#FBA94C"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="512" height="512" rx="112" fill="#0B1B3B"/>
|
||||
<text x="256" y="256" font-family="'Inter Tight','Inter',system-ui,sans-serif" font-size="230" font-weight="800" fill="url(#sunrise)" text-anchor="middle" dominant-baseline="central" letter-spacing="-8">KS</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 604 B |
10
public/logo.svg
Normal file
@ -0,0 +1,10 @@
|
||||
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="sunrise" x1="128" y1="140" x2="384" y2="372" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#F26B3A"/>
|
||||
<stop offset="1" stop-color="#FBA94C"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="512" height="512" rx="112" fill="#0B1B3B"/>
|
||||
<text x="256" y="256" font-family="'Inter Tight','Inter',system-ui,sans-serif" font-size="230" font-weight="800" fill="url(#sunrise)" text-anchor="middle" dominant-baseline="central" letter-spacing="-8">KS</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 604 B |
BIN
public/maskable-icon-512x512.png
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
BIN
public/pwa-192x192.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
public/pwa-512x512.png
Normal file
|
After Width: | Height: | Size: 6.3 KiB |
BIN
public/pwa-64x64.png
Normal file
|
After Width: | Height: | Size: 560 B |
6
pwa-assets.config.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { defineConfig, minimal2023Preset } from '@vite-pwa/assets-generator/config'
|
||||
|
||||
export default defineConfig({
|
||||
preset: minimal2023Preset,
|
||||
images: ['public/logo.svg'],
|
||||
})
|
||||
36
src/App.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
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 { OrdersPage } from './screens/OrdersPage'
|
||||
import { CallsPage } from './screens/CallsPage'
|
||||
import { StoresPage } from './screens/StoresPage'
|
||||
import { DailyLogsPage } from './screens/DailyLogsPage'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route element={<ConsoleLayout />}>
|
||||
<Route path="/orders" element={<OrdersPage />} />
|
||||
<Route path="/orders/:instanceId" element={<OrdersPage />} />
|
||||
|
||||
<Route path="/calls" element={<CallsPage />} />
|
||||
<Route path="/calls/:instanceId" element={<CallsPage />} />
|
||||
|
||||
<Route path="/stores" element={<StoresPage />} />
|
||||
<Route path="/stores/:instanceId" element={<StoresPage />} />
|
||||
|
||||
<Route path="/daily" element={<DailyLogsPage />} />
|
||||
<Route path="/daily/:instanceId" element={<DailyLogsPage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/orders" replace />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</AuthProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
311
src/api/client.ts
Normal file
@ -0,0 +1,311 @@
|
||||
import type {
|
||||
ActivityResult,
|
||||
AiDecision,
|
||||
ApiError,
|
||||
AuditEntry,
|
||||
FormScreenResponse,
|
||||
LoginResponse,
|
||||
RecordViewParams,
|
||||
RecordViewResponse,
|
||||
User,
|
||||
} from './types';
|
||||
import { APP_ID } from './config';
|
||||
|
||||
const TOKEN_KEY = 'krishna_sales_token';
|
||||
|
||||
/**
|
||||
* HTTP client for the Zino gateway (sandbox).
|
||||
*
|
||||
* Routes are app-scoped (`/app/385/...`); login + ai-employee monitor are not.
|
||||
* JWT persists in localStorage so the session survives refresh.
|
||||
*/
|
||||
export class ZinoClient {
|
||||
readonly baseUrl: string;
|
||||
readonly workflowUuid: string;
|
||||
private token: string | null = null;
|
||||
private onAuthError?: () => void;
|
||||
|
||||
constructor(baseUrl: string, workflowUuid: string, onAuthError?: () => void) {
|
||||
this.baseUrl = baseUrl.replace(/\/+$/, '');
|
||||
this.workflowUuid = workflowUuid;
|
||||
this.onAuthError = onAuthError;
|
||||
if (typeof window !== 'undefined') this.token = localStorage.getItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
setAuthErrorHandler(fn: () => void): void {
|
||||
this.onAuthError = fn;
|
||||
}
|
||||
|
||||
setToken(token: string | null): void {
|
||||
this.token = token;
|
||||
if (typeof window === 'undefined') return;
|
||||
if (token) localStorage.setItem(TOKEN_KEY, token);
|
||||
else localStorage.removeItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
getToken(): string | null {
|
||||
return this.token;
|
||||
}
|
||||
|
||||
private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (this.token) headers['Authorization'] = `Bearer ${this.token}`;
|
||||
|
||||
return this.send<T>(method, path, {
|
||||
headers,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// Multipart variant — no Content-Type header (the browser sets the boundary).
|
||||
private async requestForm<T>(path: string, form: FormData): Promise<T> {
|
||||
const headers: Record<string, string> = {};
|
||||
if (this.token) headers['Authorization'] = `Bearer ${this.token}`;
|
||||
return this.send<T>('POST', path, { headers, body: form });
|
||||
}
|
||||
|
||||
private async send<T>(method: string, path: string, init: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${this.baseUrl}${path}`, { method, ...init });
|
||||
|
||||
if (res.status === 401) {
|
||||
this.setToken(null);
|
||||
this.onAuthError?.();
|
||||
throw { status: 401, message: 'Unauthorized' } as ApiError;
|
||||
}
|
||||
if (!res.ok) {
|
||||
let message = res.statusText;
|
||||
try {
|
||||
const j = (await res.json()) as { error?: string };
|
||||
if (j.error) message = j.error;
|
||||
} catch {
|
||||
/* non-JSON body */
|
||||
}
|
||||
throw { status: res.status, message } as ApiError;
|
||||
}
|
||||
if (res.status === 204) return undefined as T;
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
// --- Auth ---
|
||||
|
||||
async login(email: string, password: string, orgId?: string): Promise<LoginResponse> {
|
||||
const res = await this.request<LoginResponse>('POST', '/usr/login', {
|
||||
email,
|
||||
password,
|
||||
...(orgId ? { org_id: Number(orgId) } : {}),
|
||||
});
|
||||
this.setToken(res.token);
|
||||
return res;
|
||||
}
|
||||
|
||||
logout(): void {
|
||||
this.setToken(null);
|
||||
}
|
||||
|
||||
/** Decode the persisted JWT into a User (no network). */
|
||||
currentUser(): User | null {
|
||||
if (!this.token) return null;
|
||||
try {
|
||||
const p = JSON.parse(atob(this.token.split('.')[1]));
|
||||
return {
|
||||
id: String(p.user_id ?? p.sub ?? ''),
|
||||
org_id: String(p.org_id ?? ''),
|
||||
name: p.name ?? '',
|
||||
email: p.email ?? '',
|
||||
roles: p.roles ?? [],
|
||||
groups: p.groups ?? [],
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Views ---
|
||||
|
||||
recordView(rvUid: string, params: RecordViewParams = {}): Promise<RecordViewResponse> {
|
||||
return this.request<RecordViewResponse>('POST', `/app/${APP_ID}/view/recordview`, {
|
||||
rv_template_uid: rvUid,
|
||||
search_query: {
|
||||
page: params.page ?? 1,
|
||||
limit: params.limit ?? 50,
|
||||
sort_by: params.sortBy ?? '',
|
||||
sort_dir: params.sortDir ?? 'desc',
|
||||
search: params.search ?? '',
|
||||
filters: (params.filters ?? []).map((f) => ({
|
||||
field_key: f.field_key,
|
||||
value: f.value,
|
||||
value2: '',
|
||||
data_type: f.data_type ?? 'string',
|
||||
})),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
detailView(dvUid: string, instanceId: number | string): Promise<{
|
||||
config: { fields: Array<{ field_key: string; output_label: string; data_type: string }> };
|
||||
data: Record<string, unknown>;
|
||||
}> {
|
||||
return this.request(
|
||||
'GET',
|
||||
`/app/${APP_ID}/view/detailview/${dvUid}?instance_id=${encodeURIComponent(String(instanceId))}`,
|
||||
);
|
||||
}
|
||||
|
||||
audit(instanceId: number | string): Promise<AuditEntry[]> {
|
||||
return this.request<AuditEntry[]>(
|
||||
'GET',
|
||||
`/app/${APP_ID}/view/audit?instance_id=${encodeURIComponent(String(instanceId))}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** AI-employee decision stream for an instance (public monitor endpoint). */
|
||||
aiDecisions(instanceId: number | string): Promise<AiDecision[]> {
|
||||
return this.request<{ decisions: AiDecision[] }>(
|
||||
'GET',
|
||||
`/monitor/decisions?instance_id=${encodeURIComponent(String(instanceId))}`,
|
||||
).then((r) => r.decisions ?? []);
|
||||
}
|
||||
|
||||
// --- Form schema (view-service) ---
|
||||
|
||||
/** Live activity form config — fields, types, options, lookup/ocr config and
|
||||
* the designer layout. The single source of truth for rendering a form. */
|
||||
formSchema(activityId: string, instanceId?: number | string): Promise<FormScreenResponse> {
|
||||
return this.request<FormScreenResponse>('POST', `/app/${APP_ID}/view/form-screens`, {
|
||||
activity_id: activityId,
|
||||
device_type: 'desktop',
|
||||
...(instanceId != null ? { instance_id: instanceId } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
// --- Workflow execution (core) ---
|
||||
|
||||
startInstance(activityUid: string, data: Record<string, unknown>): Promise<ActivityResult> {
|
||||
return this.request<ActivityResult>('POST', `/app/${APP_ID}/start`, {
|
||||
workflow_uuid: this.workflowUuid,
|
||||
activity_id: activityUid,
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
performActivity(
|
||||
instanceId: number | string,
|
||||
activityUid: string,
|
||||
data: Record<string, unknown>,
|
||||
): Promise<ActivityResult> {
|
||||
return this.request<ActivityResult>('POST', `/app/${APP_ID}/activity`, {
|
||||
workflow_uuid: this.workflowUuid,
|
||||
instance_id: typeof instanceId === 'string' ? Number(instanceId) || instanceId : instanceId,
|
||||
activity_id: activityUid,
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
// --- RDBMS lookup records (owner-agent picker etc.) ---
|
||||
|
||||
/** Search an RDBMS lookup template. Returns the matching rows. `formData`
|
||||
* carries the current form values so the server can apply the activity's
|
||||
* `field_rules` filter_options (dependent/cascading lookups, e.g. owner
|
||||
* agents scoped to the chosen region). */
|
||||
lookupRecords(
|
||||
templateUid: string,
|
||||
opts: {
|
||||
activityId: string;
|
||||
fieldId: string;
|
||||
search?: string;
|
||||
instanceId?: number | string;
|
||||
limit?: number;
|
||||
formData?: Record<string, unknown>;
|
||||
},
|
||||
): Promise<{ records: Array<Record<string, unknown>> }> {
|
||||
return this.request('POST', `/app/${APP_ID}/rdbms-templates/${templateUid}/records`, {
|
||||
workflow_uuid: this.workflowUuid,
|
||||
activity_id: opts.activityId,
|
||||
field_id: opts.fieldId,
|
||||
instance_id: opts.instanceId || undefined,
|
||||
search: opts.search ?? '',
|
||||
limit: opts.limit ?? 50,
|
||||
offset: 0,
|
||||
form_data: opts.formData ?? {},
|
||||
});
|
||||
}
|
||||
|
||||
// --- WF Lookup Records (cross-workflow references) ---
|
||||
|
||||
/** Fetch records for a wf_lookup field. */
|
||||
wfLookupRecords(
|
||||
opts: {
|
||||
activityId: string;
|
||||
fieldId: string;
|
||||
formData?: Record<string, unknown>;
|
||||
search?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
): Promise<{ data: Array<Record<string, unknown>>; records?: Array<Record<string, unknown>> } | Array<Record<string, unknown>>> {
|
||||
return this.request('POST', `/app/${APP_ID}/wf-lookup/records`, {
|
||||
workflow_uuid: this.workflowUuid,
|
||||
activity_id: opts.activityId,
|
||||
field_id: opts.fieldId,
|
||||
form_data: opts.formData ?? {},
|
||||
search: opts.search ?? '',
|
||||
limit: opts.limit ?? 100,
|
||||
offset: opts.offset ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
// --- Dataset-backed select options (state/city cascade etc.) ---
|
||||
|
||||
/** Fetch options for a dataset-backed select. The server resolves the field's
|
||||
* dataset + filter_options from `activityId`/`fieldId`, applying the activity's
|
||||
* rules against `formData` — so e.g. City options come back already scoped to
|
||||
* the chosen State. Returns `{label, value}` rows (plus the raw dataset row). */
|
||||
datasetOptions(opts: {
|
||||
activityId: string;
|
||||
fieldId: string;
|
||||
search?: string;
|
||||
instanceId?: number | string;
|
||||
limit?: number;
|
||||
formData?: Record<string, unknown>;
|
||||
}): Promise<{ options: Array<{ label: string; value: string; _raw?: Record<string, string> }> }> {
|
||||
return this.request('POST', `/app/${APP_ID}/dataset-options`, {
|
||||
workflow_uuid: this.workflowUuid,
|
||||
activity_id: opts.activityId,
|
||||
field_id: opts.fieldId,
|
||||
instance_id: opts.instanceId || undefined,
|
||||
search: opts.search ?? '',
|
||||
limit: opts.limit ?? 200,
|
||||
offset: 0,
|
||||
form_data: opts.formData ?? {},
|
||||
});
|
||||
}
|
||||
|
||||
// --- File upload + OCR (multipart, field-scoped) ---
|
||||
|
||||
private fieldForm(file: File, ctx: FieldContext): FormData {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
form.append('workflow_uuid', this.workflowUuid);
|
||||
form.append('activity_id', ctx.activityId);
|
||||
form.append('field_id', ctx.fieldId);
|
||||
if (ctx.instanceId != null) form.append('instance_id', String(ctx.instanceId));
|
||||
return form;
|
||||
}
|
||||
|
||||
/** Upload a form-field file; returns the FileMeta to store as the field value. */
|
||||
uploadFile(file: File, ctx: FieldContext): Promise<Record<string, unknown>> {
|
||||
return this.requestForm(`/app/${APP_ID}/upload`, this.fieldForm(file, ctx));
|
||||
}
|
||||
|
||||
/** Run OCR on a document; `extracted` is keyed by the field's extraction keys. */
|
||||
extractOcr(file: File, ctx: FieldContext): Promise<{ extracted: Record<string, unknown>; raw?: string; parse_error?: string }> {
|
||||
return this.requestForm(`/app/${APP_ID}/ocr-extract`, this.fieldForm(file, ctx));
|
||||
}
|
||||
}
|
||||
|
||||
/** Identifiers the field-scoped CORE endpoints resolve config + RBAC from. */
|
||||
export interface FieldContext {
|
||||
activityId: string;
|
||||
fieldId: string;
|
||||
instanceId?: number | string;
|
||||
}
|
||||
49
src/api/clients.ts
Normal file
@ -0,0 +1,49 @@
|
||||
// Per-workflow ZinoClient singletons. app 434 has three workflows, each with
|
||||
// its own workflow_uuid, so each needs its own client (ZinoClient binds one
|
||||
// workflow uuid at construction). Import the one you need, or use clientFor().
|
||||
import { ZinoClient } from './client';
|
||||
import { BASE_URL, WORKFLOWS } from './config';
|
||||
|
||||
export type WorkflowSlug = keyof typeof WORKFLOWS;
|
||||
|
||||
const cache = new Map<WorkflowSlug, ZinoClient>();
|
||||
|
||||
/** Shared client for a workflow (memoized so the JWT/session is reused). */
|
||||
export function clientFor(slug: WorkflowSlug): ZinoClient {
|
||||
let c = cache.get(slug);
|
||||
if (!c) {
|
||||
c = new ZinoClient(BASE_URL, WORKFLOWS[slug].workflowUuid);
|
||||
cache.set(slug, c);
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
export const orderBookingClient = clientFor('orderBooking');
|
||||
export const storeClient = clientFor('store');
|
||||
export const dailyReportsClient = clientFor('dailyReports');
|
||||
|
||||
// The user JWT (from /usr/login) is valid across every workflow, but each
|
||||
// client holds its own copy — so auth helpers fan out to all of them.
|
||||
const ALL = [orderBookingClient, storeClient, dailyReportsClient];
|
||||
|
||||
/** Log in once and share the JWT with every workflow client. */
|
||||
export async function loginAll(email: string, password: string, orgId?: string) {
|
||||
const res = await orderBookingClient.login(email, password, orgId);
|
||||
ALL.forEach((c) => c.setToken(res.token));
|
||||
return res;
|
||||
}
|
||||
|
||||
/** Clear the session on every client. */
|
||||
export function logoutAll(): void {
|
||||
ALL.forEach((c) => c.setToken(null));
|
||||
}
|
||||
|
||||
/** Current shared token (null when logged out). */
|
||||
export function currentToken(): string | null {
|
||||
return orderBookingClient.getToken();
|
||||
}
|
||||
|
||||
/** Register a 401 handler on every client (e.g. to bounce back to login). */
|
||||
export function onAuthErrorAll(fn: () => void): void {
|
||||
ALL.forEach((c) => c.setAuthErrorHandler(fn));
|
||||
}
|
||||
167
src/api/config.ts
Normal file
@ -0,0 +1,167 @@
|
||||
// Krishna Sales — sandbox app 434 (base https://sandbox.getzino.in).
|
||||
// Three workflows share the app: Order Booking, Store, Daily Reports. Each has
|
||||
// its own workflow_uuid + version_uuid; instantiate one ZinoClient per workflow
|
||||
// (new ZinoClient(baseUrl, WORKFLOWS.<x>.workflowUuid)). All ids from the API
|
||||
// docs under src/docs/api.
|
||||
|
||||
export const APP_ID = '434';
|
||||
export const BASE_URL = 'https://sandbox.getzino.in';
|
||||
|
||||
// --- Order Booking (src/docs/api/order_booking.md) ---
|
||||
export const ORDER_BOOKING = {
|
||||
workflowUuid: 'b5a21627-9422-40cf-b7f5-31fa070bf42f',
|
||||
versionUuid: 'f19b2146-88e5-448a-9a51-e517ebce680a',
|
||||
activities: {
|
||||
LOG_VISIT: {
|
||||
uid: '42b7f47d-96f0-4289-a6d9-38f455ad57dd', // init
|
||||
fields: {
|
||||
selectStore: 'select_store', // wf_lookup
|
||||
dateOfVisit: 'date_of_visit', // date
|
||||
timeOfVisit: 'time_of_visit', // time
|
||||
uploadImage: 'upload_image', // image
|
||||
},
|
||||
},
|
||||
PLACE_ORDER: {
|
||||
uid: 'f66403d7-e31f-4d64-bcfd-ff5485aec8b8',
|
||||
fields: {
|
||||
dateOfOrder: 'date_of_order', // date
|
||||
orderDetails: 'order_details', // grid: br_code, sku_code, product_category, product_name, sku, bags
|
||||
totalBags: 'total_bags', // number
|
||||
totalKgs: 'total_kgs', // number
|
||||
},
|
||||
},
|
||||
PRODUCTIVITY_OF_VISIT: {
|
||||
uid: '11bd10f9-a001-470e-867f-33dee8eabe4b',
|
||||
fields: {
|
||||
isProductiveCall: 'is_productive_call', // radio
|
||||
storeStatus: 'store_status', // select
|
||||
orderReceivedChannel: 'order_received_channel', // select
|
||||
remarks: 'remarks', // longtext
|
||||
action: 'action', // radio
|
||||
},
|
||||
},
|
||||
POTENTIAL_MINING: {
|
||||
uid: 'af8ac8df-d868-4f7d-88b2-123955d69c56',
|
||||
fields: {
|
||||
potential: 'potential', // grid: product_category_, total_ordered, actual_potential, difference, reason, potential_remarks
|
||||
},
|
||||
},
|
||||
CLOSE_ORDER: {
|
||||
uid: '8580d7a2-5d31-40ab-b7ea-2a38d144e59a',
|
||||
fields: {
|
||||
remarks: 'remarks_2', // longtext
|
||||
},
|
||||
},
|
||||
},
|
||||
recordViews: {
|
||||
ORDERS: '1e424561-9a27-44f5-9b68-64d63296d837',
|
||||
CALLS: 'b4d7a710-24e7-416d-885a-31fd1e727aab',
|
||||
},
|
||||
detailViews: {
|
||||
ORDERS: '0804c6c3-6cf9-4050-94bb-fa48dd5de87d',
|
||||
CALLS: '09a32bfe-9e43-4e0f-bb7d-db89c8a2557c',
|
||||
},
|
||||
} as const;
|
||||
|
||||
// --- Store (src/docs/api/store.md) ---
|
||||
export const STORE = {
|
||||
workflowUuid: '2f827d27-b7a4-4ed9-9336-879fa46dbcba',
|
||||
versionUuid: 'ced1fb5b-c6a4-4305-b85d-3c263d9d618e',
|
||||
activities: {
|
||||
// Init (fields carry the _2 suffix).
|
||||
INIT: {
|
||||
uid: '8626a77f-9e70-448e-8ac9-2c51eae5015b', // init
|
||||
fields: {
|
||||
businessName: 'business_name_2', // text
|
||||
area: 'area_2', // text
|
||||
completeAddress: 'complete_address_2', // longtext
|
||||
pinCode: 'pin_code_2', // number
|
||||
ownerName: 'owner_name_2', // text
|
||||
phoneNumber: 'phone_number_2', // phone
|
||||
email: 'email_2', // email
|
||||
storeLocation: 'store_location_2', // geolocation
|
||||
storeImage: 'store_image_2', // image
|
||||
routeName: 'route_name_2', // select
|
||||
routeCode: 'route_code_2', // select
|
||||
subRoute: 'sub_route_2', // select
|
||||
distributorName: 'distributor_name_2', // select
|
||||
distributorOwnerName: 'distributor_owner_name_2', // text
|
||||
distributorEmail: 'distributor_email_2', // email
|
||||
distributorPhoneNumber: 'distributor_phone_number_2', // phone
|
||||
notes: 'notes_2', // longtext
|
||||
potential: 'potential_2', // grid: product_category_1, quantity_1
|
||||
},
|
||||
},
|
||||
// Edit (same fields, _3 suffix).
|
||||
EDIT_STORE: {
|
||||
uid: '87647fae-3b07-445f-9615-21881b163276',
|
||||
fields: {
|
||||
businessName: 'business_name_3', // text
|
||||
area: 'area_3', // text
|
||||
completeAddress: 'complete_address_3', // longtext
|
||||
pinCode: 'pin_code_3', // number
|
||||
ownerName: 'owner_name_3', // text
|
||||
phoneNumber: 'phone_number_3', // phone
|
||||
email: 'email_3', // email
|
||||
storeLocation: 'store_location_3', // geolocation
|
||||
storeImage: 'store_image_3', // image
|
||||
routeName: 'route_name_3', // select
|
||||
routeCode: 'route_code_3', // select
|
||||
subRoute: 'sub_route_3', // select
|
||||
distributorName: 'distributor_name_3', // select
|
||||
distributorOwnerName: 'distributor_owner_name_3', // text
|
||||
distributorEmail: 'distributor_email_3', // email
|
||||
distributorPhoneNumber: 'distributor_phone_number_3', // phone
|
||||
notes: 'notes_3', // longtext
|
||||
potential: 'potential_3', // grid: col_1, col_2
|
||||
},
|
||||
},
|
||||
},
|
||||
recordViews: {
|
||||
STORE: 'c03b411a-8454-4d8b-8220-00095e92a5de',
|
||||
},
|
||||
detailViews: {
|
||||
STORE: 'af868269-d340-4ea9-9793-5fabe4f6ae27',
|
||||
},
|
||||
} as const;
|
||||
|
||||
// --- Daily Reports (src/docs/api/daily_reports.md) ---
|
||||
export const DAILY_REPORTS = {
|
||||
workflowUuid: 'c420dc04-2ba3-488e-902d-66bc9a2032cd',
|
||||
versionUuid: 'd4d4c738-bf74-4b44-a297-9a6653458f3a',
|
||||
activities: {
|
||||
INIT: {
|
||||
uid: 'ac04a444-9b96-463d-bc91-447fa80b028f', // init
|
||||
fields: {
|
||||
salesOfficerName: 'sales_officer_name', // app_user
|
||||
date: 'date', // date
|
||||
time: 'field_1', // time
|
||||
routeCode: 'route_code', // select
|
||||
subRoute: 'sub_route', // select
|
||||
storeImage: 'store_image', // image
|
||||
dayPlanNotes: 'day_plan_notes', // longtext
|
||||
},
|
||||
},
|
||||
PUNCH_OUT: {
|
||||
uid: '37c218fc-fd91-41d1-9964-fcaeade54e74',
|
||||
fields: {
|
||||
totalProductiveCalls: 'total_productive_calls', // number
|
||||
totalNonProductiveCalls: 'total_non_productive_calls', // number
|
||||
eodNotesRemarks: 'eod_notes_remarks', // longtext
|
||||
},
|
||||
},
|
||||
},
|
||||
recordViews: {
|
||||
DAILY_LOGS: '269da629-1701-477a-a37b-a78bb056d4b3',
|
||||
},
|
||||
detailViews: {
|
||||
DAILY_LOGS: '26b3c6de-a1d7-4f71-be88-7e4929932b0b',
|
||||
},
|
||||
} as const;
|
||||
|
||||
// All workflows keyed by slug, for generic lookup.
|
||||
export const WORKFLOWS = {
|
||||
orderBooking: ORDER_BOOKING,
|
||||
store: STORE,
|
||||
dailyReports: DAILY_REPORTS,
|
||||
} as const;
|
||||
223
src/api/types.ts
Normal file
@ -0,0 +1,223 @@
|
||||
// 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 TileItem {
|
||||
tile_uid: string;
|
||||
key: string;
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
export interface RecordViewResponse {
|
||||
config: {
|
||||
fields: RecordViewField[];
|
||||
filter_options?: Record<string, string[]>;
|
||||
};
|
||||
data: Record<string, unknown>[];
|
||||
pagination?: { page: number; limit: number; total_count: number; total_pages: number };
|
||||
tile_values?: TileItem[];
|
||||
chart_data?: unknown[];
|
||||
}
|
||||
|
||||
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; _raw?: Record<string, unknown> }>;
|
||||
/** 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 }>;
|
||||
};
|
||||
wf_lookup_config?: {
|
||||
workflow_uuid?: string;
|
||||
display_fields?: string[];
|
||||
[key: string]: unknown;
|
||||
};
|
||||
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;
|
||||
/** Present when data_type === 'grid' */
|
||||
columns?: FormScreenField[];
|
||||
}
|
||||
|
||||
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;
|
||||
prefill?: {
|
||||
type: string;
|
||||
value: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface FormScreenResponse {
|
||||
activity_uid: string;
|
||||
activity_name: string;
|
||||
fields: FormScreenField[];
|
||||
grid_config?: FormScreenGridItem[];
|
||||
/** Designer field_rules (cascading filter_options etc.). */
|
||||
field_rules?: FieldRule[];
|
||||
/** Per-field designer defaults (e.g. `{ assign_city: { hidden: true } }`). */
|
||||
field_defaults?: Record<string, FieldDefault>;
|
||||
/** Existing instance data if instance_id was passed. */
|
||||
data?: Record<string, unknown>;
|
||||
/** Existing instance data is sometimes returned as prefill_data. */
|
||||
prefill_data?: Record<string, unknown>;
|
||||
/** Subsequent activities to run in a chain after this form completes. */
|
||||
activity_chain?: Array<{
|
||||
activity_uid: string;
|
||||
activity_name: string;
|
||||
prefill_mappings?: any[];
|
||||
}>;
|
||||
}
|
||||
|
||||
// --- Audit (GET /app/{appId}/view/audit) ---
|
||||
|
||||
export interface AuditEntry {
|
||||
id: number;
|
||||
user_id: string;
|
||||
user_roles: string[];
|
||||
activity_id: string;
|
||||
data: Record<string, unknown>;
|
||||
execution_state?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// --- AI decisions (GET /monitor/decisions) ---
|
||||
|
||||
export interface AiDecision {
|
||||
id: number;
|
||||
ai_user_id: string;
|
||||
instance_id: string;
|
||||
activity_id: string;
|
||||
activity_name?: string;
|
||||
/** Server-resolved display name for the AI employee (preferred over the
|
||||
* static AI_EMPLOYEES map). */
|
||||
employee_name?: string;
|
||||
reasoning: string;
|
||||
confidence: number;
|
||||
status: string;
|
||||
instance_state?: string;
|
||||
knowledge_sources?: unknown;
|
||||
tool_calls?: unknown;
|
||||
/** The activity payload the AI employee submitted — e.g. the Aria Advisor
|
||||
* product recommendation carries ai_recommended_product_input /
|
||||
* ai_suggested_sum_assured_input / ai_recommendation_confidence_input here. */
|
||||
data?: Record<string, unknown>;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// --- Workflow execution (core) ---
|
||||
|
||||
export interface ActivityResult {
|
||||
success?: boolean;
|
||||
instance_id?: number | string;
|
||||
data?: Record<string, unknown>;
|
||||
message?: string;
|
||||
status_code?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Several recordview fields come back as structured objects, not scalars.
|
||||
// Normalize via the helpers in adapters.ts before rendering/seeding.
|
||||
export interface PhoneValue {
|
||||
dial_code?: string;
|
||||
phone?: string;
|
||||
phone_with_dial_code?: string;
|
||||
}
|
||||
|
||||
21
src/auth/AuthProvider.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { loginAll, logoutAll, currentToken } from '../api/clients';
|
||||
import { AuthCtx, type AuthValue } from './context';
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [authed, setAuthed] = useState(() => !!currentToken());
|
||||
|
||||
const value: AuthValue = {
|
||||
authed,
|
||||
login: async (email, password, orgId) => {
|
||||
await loginAll(email, password, orgId);
|
||||
setAuthed(true);
|
||||
},
|
||||
logout: () => {
|
||||
logoutAll();
|
||||
setAuthed(false);
|
||||
},
|
||||
};
|
||||
|
||||
return <AuthCtx.Provider value={value}>{children}</AuthCtx.Provider>;
|
||||
}
|
||||
15
src/auth/context.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
export interface AuthValue {
|
||||
authed: boolean;
|
||||
login: (email: string, password: string, orgId?: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
export const AuthCtx = createContext<AuthValue | null>(null);
|
||||
|
||||
export function useAuth(): AuthValue {
|
||||
const v = useContext(AuthCtx);
|
||||
if (!v) throw new Error('useAuth must be used within <AuthProvider>');
|
||||
return v;
|
||||
}
|
||||
65
src/components/buttons/Button.tsx
Normal file
@ -0,0 +1,65 @@
|
||||
import type { ButtonHTMLAttributes, ReactNode } from 'react';
|
||||
import { cn } from '../../lib/cn';
|
||||
|
||||
export type ButtonVariant = 'primary' | 'navy' | 'secondary' | 'ghost' | 'danger';
|
||||
export type ButtonSize = 'sm' | 'md' | 'lg';
|
||||
|
||||
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
/** Visual style. @default "primary" */
|
||||
variant?: ButtonVariant;
|
||||
/** @default "md" */
|
||||
size?: ButtonSize;
|
||||
iconLeft?: ReactNode;
|
||||
iconRight?: ReactNode;
|
||||
/** Stretch to container width. @default false */
|
||||
full?: boolean;
|
||||
}
|
||||
|
||||
const SIZES: Record<ButtonSize, string> = {
|
||||
sm: 'h-[34px] px-3.5 text-[13px] rounded-sm',
|
||||
md: 'h-[42px] px-[18px] text-base rounded-md',
|
||||
lg: 'h-[50px] px-6 text-md rounded-md',
|
||||
};
|
||||
|
||||
const VARIANTS: Record<ButtonVariant, string> = {
|
||||
primary: 'bg-sunrise text-white border border-transparent shadow-sunrise',
|
||||
navy: 'bg-navy-900 text-on-navy border border-transparent shadow-sm',
|
||||
secondary: 'bg-card text-strong border border-border-default shadow-xs',
|
||||
ghost: 'bg-transparent text-body border border-transparent',
|
||||
danger: 'bg-ruby-600 text-white border border-transparent shadow-sm',
|
||||
};
|
||||
|
||||
/**
|
||||
* Primary action button. Sunrise gradient for primary, solid navy for
|
||||
* secondary emphasis, quiet ghost for tertiary.
|
||||
*/
|
||||
export function Button({
|
||||
children,
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
iconLeft,
|
||||
iconRight,
|
||||
full = false,
|
||||
className,
|
||||
...rest
|
||||
}: ButtonProps) {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center gap-2 font-sans font-semibold leading-none',
|
||||
'cursor-pointer transition-[transform,filter,box-shadow] duration-150',
|
||||
'hover:brightness-[0.98] active:scale-[0.97]',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed disabled:active:scale-100',
|
||||
SIZES[size],
|
||||
VARIANTS[variant],
|
||||
full ? 'w-full' : 'w-auto',
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
{iconLeft}
|
||||
{children}
|
||||
{iconRight}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
36
src/components/buttons/IconButton.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
import type { ButtonHTMLAttributes, ReactNode } from 'react';
|
||||
import { cn } from '../../lib/cn';
|
||||
|
||||
export interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
/** The icon (e.g. a lucide-react element). */
|
||||
icon: ReactNode;
|
||||
/** Accessible label — required since there's no visible text. */
|
||||
label: string;
|
||||
/** @default "md" */
|
||||
size?: 'sm' | 'md';
|
||||
}
|
||||
|
||||
const SIZES = {
|
||||
sm: 'h-8 w-8',
|
||||
md: 'h-[42px] w-[42px]',
|
||||
} as const;
|
||||
|
||||
/** Square icon-only button. Quiet by default; use for row/toolbar actions. */
|
||||
export function IconButton({ icon, label, size = 'md', className, ...rest }: IconButtonProps) {
|
||||
return (
|
||||
<button
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center rounded-md border border-border-default bg-card text-muted shadow-xs',
|
||||
'cursor-pointer transition-colors duration-150 hover:text-strong hover:bg-slate-50',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
SIZES[size],
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
4
src/components/buttons/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export { Button } from './Button';
|
||||
export type { ButtonProps, ButtonVariant, ButtonSize } from './Button';
|
||||
export { IconButton } from './IconButton';
|
||||
export type { IconButtonProps } from './IconButton';
|
||||
17
src/components/dv/CallDetail.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
import { orderBookingClient } from '../../api/clients';
|
||||
import { ORDER_BOOKING } from '../../api/config';
|
||||
import { DetailView } from './DetailView';
|
||||
import type { WiredDetailViewProps } from './OrderDetail';
|
||||
|
||||
/** Call detail view (Order Booking workflow). */
|
||||
export function CallDetail({ instanceId, columns }: WiredDetailViewProps) {
|
||||
return (
|
||||
<DetailView
|
||||
client={orderBookingClient}
|
||||
dvUid={ORDER_BOOKING.detailViews.CALLS}
|
||||
instanceId={instanceId}
|
||||
title="Call"
|
||||
columns={columns}
|
||||
/>
|
||||
);
|
||||
}
|
||||
17
src/components/dv/DailyLogDetail.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
import { dailyReportsClient } from '../../api/clients';
|
||||
import { DAILY_REPORTS } from '../../api/config';
|
||||
import { DetailView } from './DetailView';
|
||||
import type { WiredDetailViewProps } from './OrderDetail';
|
||||
|
||||
/** Daily Log detail view (Daily Reports workflow). */
|
||||
export function DailyLogDetail({ instanceId, columns }: WiredDetailViewProps) {
|
||||
return (
|
||||
<DetailView
|
||||
client={dailyReportsClient}
|
||||
dvUid={DAILY_REPORTS.detailViews.DAILY_LOGS}
|
||||
instanceId={instanceId}
|
||||
title="Daily Log"
|
||||
columns={columns}
|
||||
/>
|
||||
);
|
||||
}
|
||||
178
src/components/dv/DetailView.tsx
Normal file
@ -0,0 +1,178 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { cn } from '../../lib/cn';
|
||||
import { formatValue } from '../../lib/format';
|
||||
import type { ZinoClient } from '../../api/client';
|
||||
import type { AuditEntry } from '../../api/types';
|
||||
import { WORKFLOWS } from '../../api/config';
|
||||
import { Card } from '../reusable/Card';
|
||||
import { Spinner } from '../reusable/Spinner';
|
||||
import { EmptyState } from '../reusable/EmptyState';
|
||||
|
||||
export interface DetailViewProps {
|
||||
/** Workflow-bound client (see api/clients.ts). */
|
||||
client: ZinoClient;
|
||||
/** detailview template uid (deprecated, using audit endpoint now). */
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to resolve a human-readable activity name from its UID.
|
||||
*/
|
||||
function getActivityName(uid: string): string | undefined {
|
||||
for (const wf of Object.values(WORKFLOWS)) {
|
||||
for (const [actName, actDef] of Object.entries(wf.activities)) {
|
||||
if (actDef.uid === uid) {
|
||||
return actName
|
||||
.split('_')
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
||||
.join(' ');
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic Zino detail view. Fetches `GET /app/{id}/view/audit` and
|
||||
* renders the audit trail as a labeled definition grid.
|
||||
*/
|
||||
export function DetailView({ client, instanceId, title, columns = 2 }: DetailViewProps) {
|
||||
const [entries, setEntries] = useState<AuditEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
async function run() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const r = await client.audit(instanceId);
|
||||
if (live) setEntries(r);
|
||||
} catch (e) {
|
||||
if (live) setError((e as { message?: string })?.message ?? 'Failed to load');
|
||||
} finally {
|
||||
if (live) setLoading(false);
|
||||
}
|
||||
}
|
||||
run();
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [client, instanceId]);
|
||||
|
||||
const gridCols = { 1: 'grid-cols-1', 2: 'grid-cols-1 sm:grid-cols-2', 3: 'grid-cols-1 sm:grid-cols-3' }[columns];
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card title={title}>
|
||||
<EmptyState title="Couldn’t load record" hint={error} />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card title={title}>
|
||||
<div className="py-8 flex justify-center">
|
||||
<Spinner label="Loading…" />
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
<Card title={title}>
|
||||
<EmptyState title="No history found" />
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{entries.map((entry, i) => {
|
||||
const actName = getActivityName(entry.activity_id);
|
||||
const headerTitle = actName || (i === 0 && title ? title : 'Update');
|
||||
const updatedAt = new Date(entry.created_at).toLocaleString();
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={entry.id}
|
||||
title={headerTitle}
|
||||
action={<span className="text-xs font-medium text-faint">{updatedAt}</span>}
|
||||
>
|
||||
<dl className={cn('grid gap-x-6 gap-y-4', gridCols)}>
|
||||
{Object.entries(entry.data).map(([key, value]) => {
|
||||
const isGridArray =
|
||||
Array.isArray(value) &&
|
||||
value.length > 0 &&
|
||||
typeof value[0] === 'object' &&
|
||||
value[0] !== null &&
|
||||
!('original_name' in value[0]) &&
|
||||
!('url' in value[0]);
|
||||
|
||||
if (isGridArray) {
|
||||
const rows = value as Record<string, unknown>[];
|
||||
// Use all unique keys found across all rows just in case they differ slightly
|
||||
const headers = Array.from(new Set(rows.flatMap(r => Object.keys(r))));
|
||||
|
||||
return (
|
||||
<div key={key} className="flex flex-col gap-2 min-w-0 col-span-full mt-2 mb-4">
|
||||
<dt className="text-[10px] font-bold uppercase tracking-[0.06em] text-faint">
|
||||
{key.replace(/_/g, ' ')}
|
||||
</dt>
|
||||
<dd className="m-0 text-sm text-strong font-medium overflow-x-auto rounded border border-border-subtle shadow-sm">
|
||||
<table className="min-w-full divide-y divide-border-subtle text-left bg-white">
|
||||
<thead className="bg-slate-50">
|
||||
<tr>
|
||||
{headers.map(h => (
|
||||
<th key={h} className="px-4 py-2 text-[10px] font-bold uppercase text-muted tracking-wide whitespace-nowrap">
|
||||
{h.replace(/_/g, ' ')}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border-subtle">
|
||||
{rows.map((row, idx) => (
|
||||
<tr key={idx} className="hover:bg-slate-50/50">
|
||||
{headers.map(h => (
|
||||
<td key={h} className="px-4 py-2 whitespace-nowrap text-sm text-strong">
|
||||
{formatValue(row[h])}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={key} className="flex flex-col gap-1 min-w-0">
|
||||
<dt className="text-[10px] font-bold uppercase tracking-[0.06em] text-faint">
|
||||
{key.replace(/_/g, ' ')}
|
||||
</dt>
|
||||
<dd className="m-0 text-sm text-strong font-medium break-words">
|
||||
{formatValue(value)}
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</dl>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
21
src/components/dv/OrderDetail.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
import { orderBookingClient } from '../../api/clients';
|
||||
import { ORDER_BOOKING } from '../../api/config';
|
||||
import { DetailView } from './DetailView';
|
||||
|
||||
export interface WiredDetailViewProps {
|
||||
instanceId: number | string;
|
||||
columns?: 1 | 2 | 3;
|
||||
}
|
||||
|
||||
/** Order detail view (Order Booking workflow). */
|
||||
export function OrderDetail({ instanceId, columns }: WiredDetailViewProps) {
|
||||
return (
|
||||
<DetailView
|
||||
client={orderBookingClient}
|
||||
dvUid={ORDER_BOOKING.detailViews.ORDERS}
|
||||
instanceId={instanceId}
|
||||
title="Order"
|
||||
columns={columns}
|
||||
/>
|
||||
);
|
||||
}
|
||||
17
src/components/dv/StoreDetail.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
import { storeClient } from '../../api/clients';
|
||||
import { STORE } from '../../api/config';
|
||||
import { DetailView } from './DetailView';
|
||||
import type { WiredDetailViewProps } from './OrderDetail';
|
||||
|
||||
/** Store detail view (Store workflow). */
|
||||
export function StoreDetail({ instanceId, columns }: WiredDetailViewProps) {
|
||||
return (
|
||||
<DetailView
|
||||
client={storeClient}
|
||||
dvUid={STORE.detailViews.STORE}
|
||||
instanceId={instanceId}
|
||||
title="Store"
|
||||
columns={columns}
|
||||
/>
|
||||
);
|
||||
}
|
||||
7
src/components/dv/index.ts
Normal file
@ -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';
|
||||
374
src/components/forms/DynamicForm.tsx
Normal file
@ -0,0 +1,374 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import type { ZinoClient } from '../../api/client';
|
||||
import type { FormScreenResponse } from '../../api/types';
|
||||
import { Button } from '../buttons/Button';
|
||||
import { Spinner } from '../reusable/Spinner';
|
||||
import {
|
||||
FileInput,
|
||||
SmartGridField,
|
||||
GeolocationInput,
|
||||
PhoneInput,
|
||||
SelectField,
|
||||
TextField,
|
||||
EmailField,
|
||||
TextAreaField,
|
||||
DateField,
|
||||
TimeField,
|
||||
WfLookupField,
|
||||
RadioField,
|
||||
} from './fields';
|
||||
|
||||
export interface DynamicFormProps {
|
||||
client: ZinoClient;
|
||||
activityId: string;
|
||||
instanceId?: number | string;
|
||||
onSuccess?: () => void;
|
||||
onCancel?: () => void;
|
||||
ignorePrefill?: boolean;
|
||||
}
|
||||
|
||||
export function DynamicForm({ client, activityId: initialActivityId, instanceId: initialInstanceId, onSuccess, onCancel, ignorePrefill }: DynamicFormProps) {
|
||||
const [currentActivityId, setCurrentActivityId] = useState(initialActivityId);
|
||||
const [currentInstanceId, setCurrentInstanceId] = useState<number | string | undefined>(initialInstanceId);
|
||||
const [chainQueue, setChainQueue] = useState<Array<{ activity_uid: string; activity_name: string }>>([]);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentActivityId(initialActivityId);
|
||||
setCurrentInstanceId(initialInstanceId);
|
||||
setChainQueue([]);
|
||||
}, [initialActivityId, initialInstanceId]);
|
||||
|
||||
const [schema, setSchema] = useState<FormScreenResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
setLoading(true);
|
||||
client.formSchema(currentActivityId, currentInstanceId)
|
||||
.then(res => {
|
||||
if (mounted) {
|
||||
setSchema(res);
|
||||
const defaultValues: Record<string, unknown> = {};
|
||||
if (res.field_defaults) {
|
||||
Object.entries(res.field_defaults).forEach(([fieldId, def]) => {
|
||||
if (def.value != null) {
|
||||
defaultValues[fieldId] = def.value;
|
||||
} else if (def.prefill) {
|
||||
if (def.prefill.value === 'current_date') {
|
||||
defaultValues[fieldId] = new Date().toISOString().split('T')[0];
|
||||
} else if (def.prefill.value === 'current_time') {
|
||||
defaultValues[fieldId] = new Date().toTimeString().split(' ')[0].substring(0, 5);
|
||||
} else if (def.prefill.value === 'current_user_id') {
|
||||
const user = client.currentUser();
|
||||
defaultValues[fieldId] = user ? Number(user.id) : '';
|
||||
} else {
|
||||
defaultValues[fieldId] = def.prefill.value;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
const imageFieldIds = new Set(
|
||||
res.fields.filter(f => f.data_type === 'image' || f.data_type === 'file').map(f => f.id)
|
||||
);
|
||||
|
||||
if (!ignorePrefill) {
|
||||
if (res.prefill_data) {
|
||||
Object.entries(res.prefill_data).forEach(([k, v]) => {
|
||||
if (!imageFieldIds.has(k)) defaultValues[k] = v;
|
||||
});
|
||||
} else if (res.data) {
|
||||
Object.entries(res.data).forEach(([k, v]) => {
|
||||
if (!imageFieldIds.has(k)) defaultValues[k] = v;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setValues(defaultValues);
|
||||
setLoading(false);
|
||||
}
|
||||
})
|
||||
.catch((err: any) => {
|
||||
if (mounted) {
|
||||
setError(err?.message || 'Failed to load schema');
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
return () => { mounted = false; };
|
||||
}, [client, currentActivityId, currentInstanceId]);
|
||||
|
||||
const [values, setValues] = useState<Record<string, unknown>>({});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
|
||||
const handleFieldChange = (fieldId: string, newVal: unknown) => {
|
||||
setValues(prev => {
|
||||
const next = { ...prev, [fieldId]: newVal };
|
||||
|
||||
// Auto-calculate order_details totals
|
||||
if (fieldId === 'order_details' && Array.isArray(newVal)) {
|
||||
let totalBags = 0;
|
||||
let totalKgs = 0;
|
||||
newVal.forEach(row => {
|
||||
totalBags += Number(row.bags) || 0;
|
||||
totalKgs += Number(row.row_kgs) || 0;
|
||||
});
|
||||
next['total_bags'] = totalBags;
|
||||
next['total_kgs'] = totalKgs;
|
||||
}
|
||||
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="p-8 flex justify-center"><Spinner label="Loading form..." /></div>;
|
||||
}
|
||||
if (error || !schema) {
|
||||
return <div className="p-4 text-ruby-600">Failed to load form: {error}</div>;
|
||||
}
|
||||
|
||||
// Filter out disabled fields (usually server-generated IDs)
|
||||
const fields = schema.fields.filter(f => !f.properties?.disabled);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSubmitting(true);
|
||||
setSubmitError(null);
|
||||
try {
|
||||
const payload: Record<string, unknown> = {};
|
||||
|
||||
for (const f of fields) {
|
||||
const val = values[f.id];
|
||||
if (val == null) continue;
|
||||
|
||||
if (f.data_type === 'phone' && typeof val === 'string') {
|
||||
const phoneNum = val.replace(/^\+91\s*/, '').trim();
|
||||
payload[f.id] = {
|
||||
dial_code: '+91',
|
||||
phone: phoneNum,
|
||||
phone_with_dial_code: `+91${phoneNum}`
|
||||
};
|
||||
} else if ((f.data_type === 'image' || f.data_type === 'file') && Array.isArray(val) && val.length > 0 && val[0] instanceof File) {
|
||||
const uploadedFiles = [];
|
||||
for (const file of val) {
|
||||
const fileMeta = await client.uploadFile(file, { activityId: currentActivityId, fieldId: f.id });
|
||||
uploadedFiles.push(fileMeta);
|
||||
}
|
||||
payload[f.id] = uploadedFiles;
|
||||
} else if ((f.data_type === 'image' || f.data_type === 'file') && val instanceof File) {
|
||||
const fileMeta = await client.uploadFile(val, { activityId: currentActivityId, fieldId: f.id });
|
||||
payload[f.id] = [fileMeta];
|
||||
} else {
|
||||
payload[f.id] = val;
|
||||
}
|
||||
}
|
||||
|
||||
let res;
|
||||
if (currentInstanceId != null) {
|
||||
res = await client.performActivity(currentInstanceId, currentActivityId, payload);
|
||||
} else {
|
||||
res = await client.startInstance(currentActivityId, payload);
|
||||
}
|
||||
|
||||
let pending = [...chainQueue];
|
||||
const newActivities = (schema.activity_chain || []).filter(
|
||||
a => a.activity_uid !== currentActivityId
|
||||
);
|
||||
|
||||
// Remove any existing occurrences from pending to avoid duplicates
|
||||
pending = pending.filter(p => !newActivities.some(n => n.activity_uid === p.activity_uid));
|
||||
|
||||
// Prepend the new activities for depth-first execution (nested chaining)
|
||||
pending = [...newActivities, ...pending];
|
||||
|
||||
const nextActivity = pending.shift();
|
||||
|
||||
if (nextActivity) {
|
||||
setChainQueue(pending);
|
||||
setCurrentActivityId(nextActivity.activity_uid);
|
||||
setCurrentInstanceId(res.instance_id ?? currentInstanceId);
|
||||
} else {
|
||||
onSuccess?.();
|
||||
}
|
||||
} catch (err: any) {
|
||||
setSubmitError(err.message || 'Failed to submit form');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
{fields.map(f => {
|
||||
const type = f.data_type;
|
||||
const val = values[f.id];
|
||||
const isDisabled = schema.field_defaults?.[f.id]?.disabled;
|
||||
|
||||
const renderField = () => {
|
||||
if (type === 'wf_lookup') {
|
||||
return (
|
||||
<WfLookupField
|
||||
label={f.name}
|
||||
required={f.mandatory}
|
||||
value={(val as string | number) ?? ''}
|
||||
client={client}
|
||||
config={f.properties?.wf_lookup_config}
|
||||
activityId={currentActivityId}
|
||||
fieldId={f.id}
|
||||
formData={values}
|
||||
onChange={(newVal) => handleFieldChange(f.id, newVal)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (type.startsWith('select') || type.startsWith('multiselect')) {
|
||||
return (
|
||||
<SelectField
|
||||
label={f.name}
|
||||
required={f.mandatory}
|
||||
value={(val as string) ?? ''}
|
||||
options={f.properties?.options || []}
|
||||
onChange={(newVal) => handleFieldChange(f.id, newVal)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'radio') {
|
||||
return (
|
||||
<RadioField
|
||||
label={f.name}
|
||||
required={f.mandatory}
|
||||
value={(val as string) ?? ''}
|
||||
options={f.properties?.options || []}
|
||||
onChange={(newVal) => handleFieldChange(f.id, newVal)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (type.startsWith('image') || type.startsWith('file')) {
|
||||
return (
|
||||
<FileInput
|
||||
label={f.name}
|
||||
type={type}
|
||||
required={f.mandatory}
|
||||
value={val}
|
||||
onChange={(newVal) => handleFieldChange(f.id, newVal)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (type.startsWith('grid')) {
|
||||
return (
|
||||
<SmartGridField
|
||||
label={f.name}
|
||||
columns={f.columns || []}
|
||||
value={(val as Record<string, unknown>[]) || []}
|
||||
onChange={(newVal) => handleFieldChange(f.id, newVal)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (type.startsWith('geolocation')) {
|
||||
return (
|
||||
<GeolocationInput
|
||||
label={f.name}
|
||||
required={f.mandatory}
|
||||
value={(val as { latitude: number; longitude: number }) || null}
|
||||
onChange={(newVal) => handleFieldChange(f.id, newVal)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (type.startsWith('phone')) {
|
||||
const phoneStr = typeof val === 'object' && val !== null ? (val as any).phone || '' : (val as string) ?? '';
|
||||
return (
|
||||
<PhoneInput
|
||||
label={f.name}
|
||||
required={f.mandatory}
|
||||
value={phoneStr}
|
||||
onChange={(newVal) => handleFieldChange(f.id, newVal)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (type.startsWith('email')) {
|
||||
return (
|
||||
<EmailField
|
||||
label={f.name}
|
||||
required={f.mandatory}
|
||||
value={(val as string) ?? ''}
|
||||
onChange={(newVal) => handleFieldChange(f.id, newVal)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (type.startsWith('textarea') || type.startsWith('text_area') || type.startsWith('longtext')) {
|
||||
return (
|
||||
<TextAreaField
|
||||
label={f.name}
|
||||
required={f.mandatory}
|
||||
value={(val as string) ?? ''}
|
||||
onChange={(newVal) => handleFieldChange(f.id, newVal)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (type.startsWith('date')) {
|
||||
return (
|
||||
<DateField
|
||||
label={f.name}
|
||||
required={f.mandatory}
|
||||
value={(val as string) ?? ''}
|
||||
onChange={(newVal) => handleFieldChange(f.id, newVal)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (type.startsWith('time')) {
|
||||
return (
|
||||
<TimeField
|
||||
label={f.name}
|
||||
required={f.mandatory}
|
||||
value={(val as string) ?? ''}
|
||||
onChange={(newVal) => handleFieldChange(f.id, newVal)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TextField
|
||||
label={f.name}
|
||||
required={f.mandatory}
|
||||
type={type}
|
||||
value={(val as string) ?? ''}
|
||||
onChange={(newVal) => handleFieldChange(f.id, newVal)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const content = renderField();
|
||||
return isDisabled ? (
|
||||
<fieldset key={f.id} disabled className="opacity-60 pointer-events-none">
|
||||
{content}
|
||||
</fieldset>
|
||||
) : (
|
||||
<div key={f.id}>{content}</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{submitError && <div className="text-sm text-ruby-600 mt-2">{submitError}</div>}
|
||||
|
||||
<div className="flex items-center justify-end gap-3 mt-4 pt-4 border-t border-border-subtle">
|
||||
{onCancel && (
|
||||
<Button type="button" variant="secondary" onClick={onCancel} disabled={submitting}>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
<Button type="submit" variant="primary" disabled={submitting}>
|
||||
{submitting ? 'Submitting...' : 'Submit'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
23
src/components/forms/fields/DateField.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import { Input } from '../../reusable/Input';
|
||||
|
||||
export function DateField({
|
||||
label,
|
||||
required,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
required?: boolean;
|
||||
value: string;
|
||||
onChange: (val: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<Input
|
||||
label={label}
|
||||
required={required}
|
||||
type="date"
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
23
src/components/forms/fields/EmailField.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import { Input } from '../../reusable/Input';
|
||||
|
||||
export function EmailField({
|
||||
label,
|
||||
required,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
required?: boolean;
|
||||
value: string;
|
||||
onChange: (val: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<Input
|
||||
label={label}
|
||||
required={required}
|
||||
type="email"
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
89
src/components/forms/fields/FileInput.tsx
Normal file
@ -0,0 +1,89 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
export function ImagePreview({ file }: { file: File }) {
|
||||
const [url, setUrl] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const objectUrl = URL.createObjectURL(file);
|
||||
setUrl(objectUrl);
|
||||
return () => URL.revokeObjectURL(objectUrl);
|
||||
}, [file]);
|
||||
|
||||
if (!url) return null;
|
||||
return <img src={url} alt="Preview" className="object-cover w-full h-full" />;
|
||||
}
|
||||
|
||||
export function FileInput({
|
||||
label,
|
||||
type,
|
||||
required,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
type: 'image' | 'file' | string;
|
||||
required?: boolean;
|
||||
value: File[] | undefined | null | unknown;
|
||||
onChange: (files: File[]) => void;
|
||||
}) {
|
||||
const files = Array.isArray(value) ? value : (value instanceof File ? [value] : []);
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files) {
|
||||
const newFiles = Array.from(e.target.files);
|
||||
onChange([...files, ...newFiles]);
|
||||
}
|
||||
// reset input so the same file can be selected again if needed
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
const newFiles = [...files];
|
||||
newFiles.splice(index, 1);
|
||||
onChange(newFiles);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 font-sans">
|
||||
<span className="text-sm font-medium text-muted">
|
||||
{label}
|
||||
{required && <span className="text-ruby-600"> *</span>}
|
||||
</span>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
required={required && files.length === 0}
|
||||
accept={type === 'image' ? 'image/*' : undefined}
|
||||
onChange={handleFileChange}
|
||||
className="w-full text-sm text-strong file:mr-4 file:py-2 file:px-4 file:rounded file:border-0 file:text-sm file:font-semibold file:bg-slate-100 file:text-navy-700 hover:file:bg-slate-200"
|
||||
/>
|
||||
|
||||
{files.length > 0 && (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{files.map((file, i) => (
|
||||
<div key={i} className="relative w-24 h-24 rounded-md overflow-hidden border border-border-default shadow-sm bg-slate-50 flex-shrink-0 group">
|
||||
{type === 'image' ? (
|
||||
<ImagePreview file={file} />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center p-2 text-xs text-center break-all overflow-hidden text-muted">
|
||||
{file.name}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeFile(i)}
|
||||
className="absolute top-1 right-1 bg-ruby-600/90 hover:bg-ruby-700 text-white rounded-full w-6 h-6 flex items-center justify-center text-xs opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
title="Remove file"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
61
src/components/forms/fields/GeolocationInput.tsx
Normal file
@ -0,0 +1,61 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '../../buttons/Button';
|
||||
|
||||
export function GeolocationInput({
|
||||
label,
|
||||
required,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
required?: boolean;
|
||||
value: { latitude: number; longitude: number } | null;
|
||||
onChange: (val: { latitude: number; longitude: number } | null) => void;
|
||||
}) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchLocation = () => {
|
||||
if (!navigator.geolocation) {
|
||||
setError('Geolocation is not supported by your browser.');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => {
|
||||
onChange({
|
||||
latitude: position.coords.latitude,
|
||||
longitude: position.coords.longitude,
|
||||
});
|
||||
setLoading(false);
|
||||
},
|
||||
(err) => {
|
||||
setError(err.message);
|
||||
setLoading(false);
|
||||
},
|
||||
{ enableHighAccuracy: true }
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 font-sans">
|
||||
<span className="text-sm font-medium text-muted">
|
||||
{label}
|
||||
{required && <span className="text-ruby-600"> *</span>}
|
||||
</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button type="button" variant="secondary" size="sm" onClick={fetchLocation} disabled={loading}>
|
||||
{loading ? 'Fetching...' : 'Get Location'}
|
||||
</Button>
|
||||
{value && (
|
||||
<span className="text-sm text-strong">
|
||||
{value.latitude.toFixed(5)}, {value.longitude.toFixed(5)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{error && <span className="text-xs text-ruby-600">{error}</span>}
|
||||
{required && !value && <input type="text" className="sr-only" required />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
85
src/components/forms/fields/GridInput.tsx
Normal file
@ -0,0 +1,85 @@
|
||||
import { Button } from '../../buttons/Button';
|
||||
import { Select } from '../../reusable/Select';
|
||||
import { Input } from '../../reusable/Input';
|
||||
import type { FormScreenField } from '../../../api/types';
|
||||
|
||||
export function GridInput({
|
||||
label,
|
||||
columns,
|
||||
value = [],
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
columns: FormScreenField[];
|
||||
value: Record<string, unknown>[];
|
||||
onChange: (val: Record<string, unknown>[]) => void;
|
||||
}) {
|
||||
const addRow = () => {
|
||||
onChange([...value, {}]);
|
||||
};
|
||||
|
||||
const removeRow = (idx: number) => {
|
||||
const next = [...value];
|
||||
next.splice(idx, 1);
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
const updateRow = (idx: number, fieldId: string, val: unknown) => {
|
||||
const next = [...value];
|
||||
next[idx] = { ...next[idx], [fieldId]: val };
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 font-sans border border-border-default rounded-md p-4 bg-slate-50">
|
||||
<span className="text-sm font-semibold text-strong mb-2">{label}</span>
|
||||
{value.length === 0 ? (
|
||||
<span className="text-sm text-faint italic">No rows added.</span>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
{value.map((row, i) => (
|
||||
<div key={i} className="flex flex-col gap-3 p-3 bg-white border border-border-subtle rounded relative shadow-sm">
|
||||
<div className="absolute top-2 right-2">
|
||||
<button type="button" onClick={() => removeRow(i)} className="text-xs text-ruby-600 font-medium hover:underline">
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-xs font-bold text-muted uppercase tracking-wider">Row {i + 1}</span>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{columns.map(col => {
|
||||
const val = row[col.id];
|
||||
if (col.data_type === 'select' || col.data_type === 'multiselect') {
|
||||
const opts = col.properties?.options || [];
|
||||
return (
|
||||
<Select
|
||||
key={col.id}
|
||||
label={col.name}
|
||||
required={col.mandatory}
|
||||
value={(val as string) ?? ''}
|
||||
onChange={(e) => updateRow(i, col.id, e.target.value)}
|
||||
options={[{ value: '', label: 'Select...' }, ...opts.map((o: any) => ({ value: String(o.value), label: o.label }))]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Input
|
||||
key={col.id}
|
||||
label={col.name}
|
||||
required={col.mandatory}
|
||||
type={col.data_type === 'number' ? 'number' : col.data_type === 'email' ? 'email' : 'text'}
|
||||
value={(val as string) ?? ''}
|
||||
onChange={(e) => updateRow(i, col.id, col.data_type === 'number' ? Number(e.target.value) : e.target.value)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Button type="button" variant="secondary" size="sm" onClick={addRow} className="mt-2 self-start">
|
||||
+ Add Row
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
32
src/components/forms/fields/PhoneInput.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
import { Input } from '../../reusable/Input';
|
||||
|
||||
export function PhoneInput({
|
||||
label,
|
||||
required,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
required?: boolean;
|
||||
value: string;
|
||||
onChange: (val: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<Input
|
||||
label={label}
|
||||
required={required}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
prefix="+91"
|
||||
maxLength={10}
|
||||
placeholder="10-digit number"
|
||||
value={value ?? ''}
|
||||
onChange={(e) => {
|
||||
const numericOnly = e.target.value.replace(/\D/g, '');
|
||||
if (numericOnly.length <= 10) {
|
||||
onChange(numericOnly);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
38
src/components/forms/fields/RadioField.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
export interface RadioFieldProps {
|
||||
label: string;
|
||||
required?: boolean;
|
||||
value: string;
|
||||
options: { label: string; value: string }[];
|
||||
onChange: (val: string) => void;
|
||||
}
|
||||
|
||||
export function RadioField({ label, required, value, options, onChange }: RadioFieldProps) {
|
||||
// Fallback to Yes/No if no options provided
|
||||
const opts = options?.length ? options : [
|
||||
{ label: 'Yes', value: 'yes' },
|
||||
{ label: 'No', value: 'no' }
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-semibold text-strong">
|
||||
{label}
|
||||
{required && <span className="text-ruby-600 ml-1">*</span>}
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-4 mt-1">
|
||||
{opts.map((opt) => (
|
||||
<label key={String(opt.value)} className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
value={String(opt.value)}
|
||||
checked={String(value) === String(opt.value)}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="w-4 h-4 text-primary bg-card border-border-default focus:ring-1 focus:ring-primary focus:outline-none cursor-pointer"
|
||||
/>
|
||||
<span className="text-sm text-body">{opt.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
25
src/components/forms/fields/SelectField.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import { Select } from '../../reusable/Select';
|
||||
|
||||
export function SelectField({
|
||||
label,
|
||||
required,
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
required?: boolean;
|
||||
value: string;
|
||||
options: any[];
|
||||
onChange: (val: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<Select
|
||||
label={label}
|
||||
required={required}
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
options={[{ value: '', label: 'Select...' }, ...options.map((o: any) => ({ value: String(o.value), label: o.label }))]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
145
src/components/forms/fields/SmartGridField.tsx
Normal file
@ -0,0 +1,145 @@
|
||||
import { Button } from '../../buttons/Button';
|
||||
import { Select } from '../../reusable/Select';
|
||||
import { Input } from '../../reusable/Input';
|
||||
import type { FormScreenField } from '../../../api/types';
|
||||
|
||||
export function SmartGridField({
|
||||
label,
|
||||
columns,
|
||||
value = [],
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
columns: FormScreenField[];
|
||||
value: Record<string, unknown>[];
|
||||
onChange: (val: Record<string, unknown>[]) => void;
|
||||
}) {
|
||||
const addRow = () => onChange([...value, {}]);
|
||||
|
||||
const removeRow = (idx: number) => {
|
||||
const next = [...value];
|
||||
next.splice(idx, 1);
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
const updateRow = (idx: number, fieldId: string, val: unknown) => {
|
||||
const next = [...value];
|
||||
let row = { ...next[idx], [fieldId]: val };
|
||||
|
||||
const colDef = columns.find(c => c.id === fieldId);
|
||||
|
||||
// If product category changes, clear out the rest of the row's data
|
||||
if (colDef && (colDef.id === 'product_category' || colDef.name === 'Product Category')) {
|
||||
Object.keys(row).forEach(k => {
|
||||
if (k !== fieldId) {
|
||||
row[k] = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-fill dataset keys from _raw
|
||||
if (colDef && (colDef.data_type === 'select' || colDef.data_type === 'multiselect')) {
|
||||
const option = colDef.properties?.options?.find(o => String(o.value) === String(val));
|
||||
if (option && option._raw) {
|
||||
for (const key of Object.keys(option._raw)) {
|
||||
// Match column ID with or without underscores (e.g., brcode <-> br_code)
|
||||
const targetCol = columns.find(c => c.id === key || c.id.replace(/_/g, '') === key.replace(/_/g, ''));
|
||||
if (targetCol && targetCol.id !== fieldId) {
|
||||
row[targetCol.id] = option._raw[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-calculate row_kgs if sku and bags are present
|
||||
const skuVal = Number(row.sku) || 0;
|
||||
const bagsVal = Number(row.bags) || 0;
|
||||
row.row_kgs = skuVal * bagsVal;
|
||||
|
||||
next[idx] = row;
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 font-sans border border-border-default rounded-md p-4 bg-slate-50">
|
||||
<span className="text-sm font-semibold text-strong mb-2">{label}</span>
|
||||
{value.length === 0 ? (
|
||||
<span className="text-sm text-faint italic">No rows added.</span>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
{value.map((row, i) => (
|
||||
<div key={i} className="flex flex-col gap-3 p-3 bg-white border border-border-subtle rounded relative shadow-sm">
|
||||
<div className="absolute top-2 right-2">
|
||||
<button type="button" onClick={() => removeRow(i)} className="text-xs text-ruby-600 font-medium hover:underline">
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-xs font-bold text-muted uppercase tracking-wider">Row {i + 1}</span>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{columns.map(col => {
|
||||
const val = row[col.id];
|
||||
if (col.data_type === 'select' || col.data_type === 'multiselect') {
|
||||
// Cascade filter options
|
||||
const allOpts = col.properties?.options || [];
|
||||
let filteredOpts = allOpts;
|
||||
|
||||
// Specific logic for product_name depending on product_category
|
||||
if (col.id === 'product_name' || col.name === 'Product Name') {
|
||||
const catCol = columns.find(c => c.id === 'product_category' || c.name === 'Product Category');
|
||||
if (catCol) {
|
||||
const selectedCategory = row[catCol.id] as string;
|
||||
if (selectedCategory) {
|
||||
filteredOpts = allOpts.filter(opt => {
|
||||
const labelStr = String(opt.label || opt.value || '');
|
||||
return labelStr.startsWith(selectedCategory);
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Generic _raw cascading for other fields just in case
|
||||
filteredOpts = allOpts.filter(opt => {
|
||||
if (!opt._raw) return true;
|
||||
for (const [rowKey, rowVal] of Object.entries(row)) {
|
||||
if (rowKey === col.id || rowVal == null || rowVal === '') continue;
|
||||
const rawKey = Object.keys(opt._raw).find(rk => rk === rowKey || rk.replace(/_/g, '') === rowKey.replace(/_/g, ''));
|
||||
if (rawKey && String(opt._raw[rawKey]) !== String(rowVal)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Select
|
||||
key={col.id}
|
||||
label={col.name}
|
||||
required={col.mandatory}
|
||||
value={(val as string) ?? ''}
|
||||
onChange={(e) => updateRow(i, col.id, e.target.value)}
|
||||
options={[{ value: '', label: 'Select...' }, ...filteredOpts.map((o: any) => ({ value: String(o.value), label: o.label }))]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Input
|
||||
key={col.id}
|
||||
label={col.name}
|
||||
required={col.mandatory}
|
||||
type={col.data_type === 'number' ? 'number' : col.data_type === 'email' ? 'email' : 'text'}
|
||||
value={(val as string) ?? ''}
|
||||
onChange={(e) => updateRow(i, col.id, col.data_type === 'number' ? Number(e.target.value) : e.target.value)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Button type="button" variant="secondary" size="sm" onClick={addRow} className="mt-2 self-start">
|
||||
+ Add Row
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
22
src/components/forms/fields/TextAreaField.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import { Textarea } from '../../reusable/Textarea';
|
||||
|
||||
export function TextAreaField({
|
||||
label,
|
||||
required,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
required?: boolean;
|
||||
value: string;
|
||||
onChange: (val: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<Textarea
|
||||
label={label}
|
||||
required={required}
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
25
src/components/forms/fields/TextField.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import { Input } from '../../reusable/Input';
|
||||
|
||||
export function TextField({
|
||||
label,
|
||||
type,
|
||||
required,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
type: string;
|
||||
required?: boolean;
|
||||
value: string;
|
||||
onChange: (val: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<Input
|
||||
label={label}
|
||||
required={required}
|
||||
type={type === 'number' ? 'number' : type === 'email' ? 'email' : 'text'}
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
23
src/components/forms/fields/TimeField.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import { Input } from '../../reusable/Input';
|
||||
|
||||
export function TimeField({
|
||||
label,
|
||||
required,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
required?: boolean;
|
||||
value: string;
|
||||
onChange: (val: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<Input
|
||||
label={label}
|
||||
required={required}
|
||||
type="time"
|
||||
value={value ?? ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
80
src/components/forms/fields/WfLookupField.tsx
Normal file
@ -0,0 +1,80 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import type { ZinoClient } from '../../../api/client';
|
||||
import { SelectField } from './SelectField';
|
||||
|
||||
export interface WfLookupFieldProps {
|
||||
label: string;
|
||||
required?: boolean;
|
||||
value: string | number;
|
||||
onChange: (val: string) => void;
|
||||
client: ZinoClient;
|
||||
config: any; // wf_lookup_config
|
||||
activityId: string;
|
||||
fieldId: string;
|
||||
formData: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function WfLookupField({ label, required, value, onChange, client, config, activityId, fieldId, formData }: WfLookupFieldProps) {
|
||||
const [options, setOptions] = useState<{ label: string; value: string }[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
const wfUuid = config?.workflow_uuid;
|
||||
if (!wfUuid) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
client.wfLookupRecords({
|
||||
activityId,
|
||||
fieldId,
|
||||
formData,
|
||||
limit: 200
|
||||
})
|
||||
.then(res => {
|
||||
if (!mounted) return;
|
||||
// The API might return { data: [...] } or { records: [...] } or just an array
|
||||
const arr = Array.isArray(res) ? res : (res.data || res.records || []);
|
||||
|
||||
const displayFields = config.display_fields || [];
|
||||
|
||||
const opts = arr.map((row: any) => {
|
||||
// Join the display fields to form the label
|
||||
const labelParts = displayFields
|
||||
.map((df: any) => row[df.field_id])
|
||||
.filter((v: any) => v != null && v !== '');
|
||||
|
||||
const labelText = labelParts.length > 0
|
||||
? labelParts.join(' - ')
|
||||
: `ID: ${row.instance_id || row.id}`;
|
||||
|
||||
return {
|
||||
value: String(row.instance_id || row.id),
|
||||
label: labelText
|
||||
};
|
||||
});
|
||||
|
||||
setOptions(opts);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error("Failed to load wf_lookup records:", err);
|
||||
})
|
||||
.finally(() => {
|
||||
if (mounted) setLoading(false);
|
||||
});
|
||||
|
||||
return () => { mounted = false; };
|
||||
}, [client, config]);
|
||||
|
||||
return (
|
||||
<SelectField
|
||||
label={loading ? `${label} (Loading...)` : label}
|
||||
required={required}
|
||||
value={String(value || '')}
|
||||
onChange={onChange}
|
||||
options={options}
|
||||
/>
|
||||
);
|
||||
}
|
||||
13
src/components/forms/fields/index.ts
Normal file
@ -0,0 +1,13 @@
|
||||
export * from './FileInput';
|
||||
export * from './GridInput';
|
||||
export * from './GeolocationInput';
|
||||
export * from './PhoneInput';
|
||||
export * from './SelectField';
|
||||
export * from './TextField';
|
||||
export * from './EmailField';
|
||||
export * from './TextAreaField';
|
||||
export * from './DateField';
|
||||
export * from './TimeField';
|
||||
export * from './WfLookupField';
|
||||
export * from './RadioField';
|
||||
export * from './SmartGridField';
|
||||
6
src/components/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
// Krishna Sales component library. Single entry point:
|
||||
// import { Button, Card, RecordView, OrdersView, StoreDetail } from './components';
|
||||
export * from './buttons';
|
||||
export * from './reusable';
|
||||
export * from './rv';
|
||||
export * from './dv';
|
||||
87
src/components/reusable/AnalyticsChart.tsx
Normal file
@ -0,0 +1,87 @@
|
||||
import { Card } from './Card';
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer
|
||||
} from 'recharts';
|
||||
|
||||
export interface ChartRow {
|
||||
dimension: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface ChartConfig {
|
||||
chart_uid: string;
|
||||
key: string;
|
||||
rows: ChartRow[];
|
||||
}
|
||||
|
||||
export interface AnalyticsChartProps {
|
||||
data?: unknown[];
|
||||
}
|
||||
|
||||
export function AnalyticsChart({ data }: AnalyticsChartProps) {
|
||||
if (!data || !Array.isArray(data) || data.length === 0) return null;
|
||||
|
||||
const charts = data as ChartConfig[];
|
||||
const colors = ['#3B82F6', '#10B981', '#F59E0B', '#8B5CF6', '#EC4899', '#14B8A6'];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 w-full">
|
||||
{charts.map((chart, idx) => {
|
||||
const title = (chart.key || `Chart ${idx + 1}`)
|
||||
.replace(/_/g, ' ')
|
||||
.replace(/\b\w/g, c => c.toUpperCase());
|
||||
|
||||
// Safely format dimension string (fallbacks for empty strings)
|
||||
const formattedRows = (chart.rows || []).map(r => ({
|
||||
...r,
|
||||
dimension: String(r.dimension || '').trim() || 'Unknown'
|
||||
}));
|
||||
|
||||
if (formattedRows.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Card key={chart.chart_uid || idx} title={title} className="shadow-sm border-t-4 border-t-indigo-500">
|
||||
<div className="h-[320px] w-full mt-4">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={formattedRows} margin={{ top: 10, right: 10, left: -20, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#E2E8F0" />
|
||||
<XAxis
|
||||
dataKey="dimension"
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fontSize: 12, fill: '#64748B' }}
|
||||
dy={10}
|
||||
/>
|
||||
<YAxis
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tick={{ fontSize: 12, fill: '#64748B' }}
|
||||
allowDecimals={false}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ fill: '#F8FAFC' }}
|
||||
contentStyle={{ borderRadius: '8px', border: '1px solid #E2E8F0', boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)', fontSize: '14px', fontFamily: 'inherit' }}
|
||||
labelStyle={{ fontWeight: 'bold', color: '#0F172A', marginBottom: '4px' }}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="value"
|
||||
name="Value"
|
||||
fill={colors[idx % colors.length]}
|
||||
radius={[4, 4, 0, 0]}
|
||||
maxBarSize={40}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
51
src/components/reusable/Badge.tsx
Normal file
@ -0,0 +1,51 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { cn } from '../../lib/cn';
|
||||
|
||||
export type BadgeTone =
|
||||
| 'neutral'
|
||||
| 'navy'
|
||||
| 'success'
|
||||
| 'warning'
|
||||
| 'danger'
|
||||
| 'info'
|
||||
| 'accent';
|
||||
|
||||
export interface BadgeProps {
|
||||
children?: ReactNode;
|
||||
/** @default "neutral" */
|
||||
tone?: BadgeTone;
|
||||
/** Show a leading status dot. @default false */
|
||||
dot?: boolean;
|
||||
/** @default "md" */
|
||||
size?: 'sm' | 'md';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const TONES: Record<BadgeTone, { wrap: string; dot: string }> = {
|
||||
neutral: { wrap: 'bg-slate-100 text-slate-700', dot: 'bg-slate-500' },
|
||||
navy: { wrap: 'bg-navy-900 text-white', dot: 'bg-sunrise-400' },
|
||||
success: { wrap: 'bg-emerald-100 text-emerald-700', dot: 'bg-emerald-600' },
|
||||
warning: { wrap: 'bg-amber-100 text-amber-700', dot: 'bg-amber-600' },
|
||||
danger: { wrap: 'bg-ruby-100 text-ruby-700', dot: 'bg-ruby-600' },
|
||||
info: { wrap: 'bg-sky-100 text-sky-700', dot: 'bg-sky-600' },
|
||||
accent: { wrap: 'bg-sunrise-100 text-sunrise-600', dot: 'bg-sunrise-500' },
|
||||
};
|
||||
|
||||
/** Compact status pill with optional leading dot. */
|
||||
export function Badge({ children, tone = 'neutral', dot = false, size = 'md', className }: BadgeProps) {
|
||||
const t = TONES[tone];
|
||||
const sm = size === 'sm';
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-pill font-sans font-semibold leading-none whitespace-nowrap',
|
||||
sm ? 'text-2xs px-2 py-1' : 'text-xs px-2.5 py-[5px]',
|
||||
t.wrap,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{dot && <span className={cn('w-1.5 h-1.5 rounded-full', t.dot)} />}
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
58
src/components/reusable/Card.tsx
Normal file
@ -0,0 +1,58 @@
|
||||
import type { CSSProperties, ReactNode } from 'react';
|
||||
import { cn } from '../../lib/cn';
|
||||
|
||||
export type Elevation = 'none' | 'xs' | 'sm' | 'md' | 'lg';
|
||||
|
||||
export interface CardProps {
|
||||
children?: ReactNode;
|
||||
/** Optional header title. */
|
||||
title?: ReactNode;
|
||||
/** Optional header trailing node (button, menu). */
|
||||
action?: ReactNode;
|
||||
/** Apply body padding. @default true */
|
||||
pad?: boolean;
|
||||
/** @default "sm" */
|
||||
elevation?: Elevation;
|
||||
className?: string;
|
||||
bodyClassName?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
const SHADOW: Record<Elevation, string> = {
|
||||
none: 'shadow-none',
|
||||
xs: 'shadow-xs',
|
||||
sm: 'shadow-sm',
|
||||
md: 'shadow-md',
|
||||
lg: 'shadow-lg',
|
||||
};
|
||||
|
||||
/** Surface container with soft elevation. Optional header & padding control. */
|
||||
export function Card({
|
||||
children,
|
||||
title,
|
||||
action,
|
||||
pad = true,
|
||||
elevation = 'sm',
|
||||
className,
|
||||
bodyClassName,
|
||||
style,
|
||||
}: CardProps) {
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
'bg-card rounded-lg border border-border-subtle overflow-hidden font-sans',
|
||||
SHADOW[elevation],
|
||||
className,
|
||||
)}
|
||||
style={style}
|
||||
>
|
||||
{title && (
|
||||
<header className="flex items-center justify-between px-5 py-4 border-b border-border-subtle">
|
||||
<h3 className="m-0 text-md font-semibold text-strong">{title}</h3>
|
||||
{action}
|
||||
</header>
|
||||
)}
|
||||
<div className={cn(pad ? 'p-5' : 'p-0', bodyClassName)}>{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
24
src/components/reusable/EmptyState.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { cn } from '../../lib/cn';
|
||||
|
||||
export interface EmptyStateProps {
|
||||
/** Optional leading icon. */
|
||||
icon?: ReactNode;
|
||||
title: string;
|
||||
hint?: string;
|
||||
/** Optional action (e.g. a Button). */
|
||||
action?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Centered placeholder for empty lists / error fallbacks. */
|
||||
export function EmptyState({ icon, title, hint, action, className }: EmptyStateProps) {
|
||||
return (
|
||||
<div className={cn('flex flex-col items-center justify-center gap-2 px-6 py-12 text-center', className)}>
|
||||
{icon && <div className="text-faint mb-1">{icon}</div>}
|
||||
<div className="text-sm font-semibold text-strong">{title}</div>
|
||||
{hint && <div className="text-xs text-faint max-w-[36ch]">{hint}</div>}
|
||||
{action && <div className="mt-3">{action}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
60
src/components/reusable/Input.tsx
Normal file
@ -0,0 +1,60 @@
|
||||
import type { InputHTMLAttributes, ReactNode } from 'react';
|
||||
import { cn } from '../../lib/cn';
|
||||
|
||||
export interface InputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'prefix'> {
|
||||
label?: string;
|
||||
hint?: string;
|
||||
error?: string;
|
||||
/** Leading adornment, e.g. "₹". */
|
||||
prefix?: ReactNode;
|
||||
/** Trailing adornment, e.g. "/ year". */
|
||||
suffix?: ReactNode;
|
||||
/** Class for the outer label wrapper. */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Labeled text/number input with optional prefix, suffix, hint and error. */
|
||||
export function Input({
|
||||
label,
|
||||
hint,
|
||||
error,
|
||||
prefix,
|
||||
suffix,
|
||||
required,
|
||||
className,
|
||||
disabled,
|
||||
...rest
|
||||
}: InputProps) {
|
||||
return (
|
||||
<label className={cn('flex flex-col gap-1.5 font-sans', disabled && 'cursor-not-allowed', className)}>
|
||||
{label && (
|
||||
<span className="text-sm font-medium text-muted">
|
||||
{label}
|
||||
{required && <span className="text-ruby-600"> *</span>}
|
||||
</span>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-md px-3 h-[42px] border transition-[border-color,box-shadow] duration-150',
|
||||
disabled ? 'bg-sunk border-border-subtle' : 'bg-card',
|
||||
error ? 'border-ruby-600' : !disabled && 'border-border-default focus-ring',
|
||||
)}
|
||||
>
|
||||
{prefix && <span className="text-base font-semibold text-faint">{prefix}</span>}
|
||||
<input
|
||||
required={required}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'flex-1 w-full border-none outline-none bg-transparent font-sans text-base',
|
||||
disabled ? 'text-muted cursor-not-allowed' : 'text-strong',
|
||||
)}
|
||||
{...rest}
|
||||
/>
|
||||
{suffix && <span className="text-faint text-sm">{suffix}</span>}
|
||||
</div>
|
||||
{(hint || error) && (
|
||||
<span className={cn('text-xs', error ? 'text-ruby-600' : 'text-faint')}>{error || hint}</span>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
84
src/components/reusable/Modal.tsx
Normal file
@ -0,0 +1,84 @@
|
||||
import { useEffect, type ReactNode } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { X } from 'lucide-react';
|
||||
import { cn } from '../../lib/cn';
|
||||
|
||||
export type ModalWidth = 'sm' | 'md' | 'lg' | 'xl';
|
||||
|
||||
const WIDTH: Record<ModalWidth, string> = {
|
||||
sm: 'max-w-[480px]',
|
||||
md: 'max-w-[640px]',
|
||||
lg: 'max-w-[820px]',
|
||||
xl: 'max-w-[1000px]',
|
||||
};
|
||||
|
||||
export interface ModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title?: ReactNode;
|
||||
subtitle?: ReactNode;
|
||||
/** @default "md" */
|
||||
width?: ModalWidth;
|
||||
actions?: ReactNode;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
/** Portal modal host — backdrop, Esc / click-out close, scroll-locked body. */
|
||||
export function Modal({ open, onClose, title, subtitle, width = 'md', actions, children }: ModalProps) {
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
document.addEventListener('keydown', onKey);
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKey);
|
||||
document.body.style.overflow = prev;
|
||||
};
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-[10000] flex items-end sm:items-center justify-center overflow-y-auto bg-black/40 sm:p-8"
|
||||
onMouseDown={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
className={cn(
|
||||
'w-full bg-card rounded-t-2xl sm:rounded-lg border border-border-subtle shadow-lg sm:my-auto',
|
||||
'flex flex-col max-h-[92vh] sm:max-h-[85vh]',
|
||||
WIDTH[width],
|
||||
)}
|
||||
>
|
||||
<div className="shrink-0 flex justify-center pt-2 pb-1 sm:hidden">
|
||||
<span className="w-9 h-1 rounded-full bg-border-default" />
|
||||
</div>
|
||||
<header className="shrink-0 flex items-start justify-between gap-3 px-5 py-4 border-b border-border-subtle">
|
||||
<div className="min-w-0">
|
||||
{title && <h3 className="m-0 text-md font-semibold text-strong truncate">{title}</h3>}
|
||||
{subtitle && <div className="text-xs text-faint mt-0.5">{subtitle}</div>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{actions && <div className="flex items-center gap-2 mr-2">{actions}</div>}
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
className="shrink-0 -mr-1 -mt-1 p-1.5 rounded-md text-faint hover:text-strong hover:bg-black/5"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto p-5 pb-[calc(1.25rem+env(safe-area-inset-bottom))] scrollbar-slim">{children}</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
41
src/components/reusable/Pagination.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { Button } from '../buttons/Button';
|
||||
|
||||
export interface PaginationProps {
|
||||
/** 1-based current page. */
|
||||
page: number;
|
||||
pageSize: number;
|
||||
/** Total rows across all pages. */
|
||||
total: number;
|
||||
onPage: (page: number) => void;
|
||||
}
|
||||
|
||||
/** Pager: "Showing a–b of N" + Prev/Next. Renders nothing when the set fits on
|
||||
* one page. Shared by the record-view tables. */
|
||||
export function Pagination({ page, pageSize, total, onPage }: PaginationProps) {
|
||||
if (total <= 0) return null;
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
const safePage = Math.min(Math.max(page, 1), totalPages);
|
||||
const start = (safePage - 1) * pageSize;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 border-t border-border-subtle px-[18px] py-3">
|
||||
<span className="text-xs text-faint">
|
||||
Showing {start + 1}–{Math.min(start + pageSize, total)} of {total}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="secondary" size="sm" disabled={safePage <= 1} onClick={() => onPage(safePage - 1)}>
|
||||
<ChevronLeft size={15} />
|
||||
Prev
|
||||
</Button>
|
||||
<span className="px-1 text-xs font-medium text-muted nums">
|
||||
{safePage} / {totalPages}
|
||||
</span>
|
||||
<Button variant="secondary" size="sm" disabled={safePage >= totalPages} onClick={() => onPage(safePage + 1)}>
|
||||
Next
|
||||
<ChevronRight size={15} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
50
src/components/reusable/Select.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
import type { SelectHTMLAttributes } from 'react';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import { cn } from '../../lib/cn';
|
||||
|
||||
export interface SelectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
|
||||
label?: string;
|
||||
hint?: string;
|
||||
/** Either strings or {value,label} objects. */
|
||||
options?: Array<string | SelectOption>;
|
||||
/** Class for the outer label wrapper. */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Labeled native select styled to match Input. */
|
||||
export function Select({ label, hint, options = [], required, className, ...rest }: SelectProps) {
|
||||
return (
|
||||
<label className={cn('flex flex-col gap-1.5 font-sans', className)}>
|
||||
{label && (
|
||||
<span className="text-sm font-medium text-muted">
|
||||
{label}
|
||||
{required && <span className="text-ruby-600"> *</span>}
|
||||
</span>
|
||||
)}
|
||||
<div className="relative bg-card rounded-md h-[42px] border border-border-default transition-[border-color,box-shadow] duration-150 focus-ring">
|
||||
<select
|
||||
required={required}
|
||||
className="w-full h-full border-none outline-none bg-transparent appearance-none pl-3 pr-9 font-sans text-base text-strong cursor-pointer"
|
||||
{...rest}
|
||||
>
|
||||
{options.map((o) => {
|
||||
const val = typeof o === 'string' ? o : o.value;
|
||||
const lab = typeof o === 'string' ? o : o.label;
|
||||
return (
|
||||
<option key={val} value={val}>
|
||||
{lab}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
<ChevronDown size={15} className="absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none text-faint" />
|
||||
</div>
|
||||
{hint && <span className="text-xs text-faint">{hint}</span>}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
20
src/components/reusable/Spinner.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { cn } from '../../lib/cn';
|
||||
|
||||
export interface SpinnerProps {
|
||||
/** Pixel size of the icon. @default 18 */
|
||||
size?: number;
|
||||
/** Optional caption shown beside the spinner. */
|
||||
label?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Inline loading indicator. */
|
||||
export function Spinner({ size = 18, label, className }: SpinnerProps) {
|
||||
return (
|
||||
<span className={cn('inline-flex items-center gap-2 text-faint', className)}>
|
||||
<Loader2 size={size} className="animate-spin" />
|
||||
{label && <span className="text-sm">{label}</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
60
src/components/reusable/StatsTiles.tsx
Normal file
@ -0,0 +1,60 @@
|
||||
import { Card } from "./Card";
|
||||
import type { TileItem } from "../../api/types";
|
||||
|
||||
export interface StatsTilesProps {
|
||||
tiles?: TileItem[];
|
||||
}
|
||||
|
||||
const colors = [
|
||||
"from-blue-500 to-cyan-500",
|
||||
"from-emerald-500 to-green-500",
|
||||
"from-orange-500 to-amber-500",
|
||||
"from-violet-500 to-fuchsia-500",
|
||||
"from-pink-500 to-rose-500",
|
||||
"from-indigo-500 to-blue-500",
|
||||
];
|
||||
|
||||
export function StatsTiles({ tiles }: StatsTilesProps) {
|
||||
if (!tiles?.length) return null;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-5 md:grid-cols-4">
|
||||
{tiles.map((tile, idx) => {
|
||||
const displayLabel = (tile.key || `tile_${idx}`)
|
||||
.replace(/_/g, " ")
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
|
||||
const gradient = colors[idx % colors.length];
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={tile.tile_uid || idx}
|
||||
className="group relative overflow-hidden rounded-2xl border border-gray-200 bg-white p-6 shadow-sm transition-all duration-300 hover:-translate-y-1 hover:shadow-xl"
|
||||
>
|
||||
{/* Top Gradient */}
|
||||
<div
|
||||
className={`absolute left-0 top-0 h-1.5 w-full bg-gradient-to-r ${gradient}`}
|
||||
/>
|
||||
|
||||
{/* Background Decoration */}
|
||||
<div
|
||||
className={`absolute -right-6 -top-6 h-24 w-24 rounded-full bg-gradient-to-br ${gradient} opacity-10 transition-all duration-300 group-hover:scale-125`}
|
||||
/>
|
||||
|
||||
<div className="relative flex flex-col gap-3">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.15em] text-gray-500">
|
||||
{displayLabel}
|
||||
</p>
|
||||
|
||||
<h2 className="text-4xl font-bold tracking-tight text-gray-900">
|
||||
{(tile.value as React.ReactNode) ?? "-"}
|
||||
</h2>
|
||||
|
||||
<div className="h-1 w-12 rounded-full bg-gray-200 transition-all duration-300 group-hover:w-20 group-hover:bg-blue-500" />
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
50
src/components/reusable/Textarea.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
import type { TextareaHTMLAttributes } from 'react';
|
||||
import { cn } from '../../lib/cn';
|
||||
|
||||
export interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||
label?: string;
|
||||
hint?: string;
|
||||
error?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Textarea({
|
||||
label,
|
||||
hint,
|
||||
error,
|
||||
required,
|
||||
className,
|
||||
disabled,
|
||||
...rest
|
||||
}: TextareaProps) {
|
||||
return (
|
||||
<label className={cn('flex flex-col gap-1.5 font-sans', disabled && 'cursor-not-allowed', className)}>
|
||||
{label && (
|
||||
<span className="text-sm font-medium text-muted">
|
||||
{label}
|
||||
{required && <span className="text-ruby-600"> *</span>}
|
||||
</span>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
'flex rounded-md border transition-[border-color,box-shadow] duration-150 overflow-hidden',
|
||||
disabled ? 'bg-sunk border-border-subtle' : 'bg-card',
|
||||
error ? 'border-ruby-600' : !disabled && 'border-border-default focus-within:ring-2 focus-within:ring-navy-600/20 focus-within:border-navy-600',
|
||||
)}
|
||||
>
|
||||
<textarea
|
||||
required={required}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'flex-1 w-full min-h-[80px] p-3 border-none outline-none bg-transparent font-sans text-base resize-y',
|
||||
disabled ? 'text-muted cursor-not-allowed' : 'text-strong',
|
||||
)}
|
||||
{...rest}
|
||||
/>
|
||||
</div>
|
||||
{(hint || error) && (
|
||||
<span className={cn('text-xs', error ? 'text-ruby-600' : 'text-faint')}>{error || hint}</span>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
18
src/components/reusable/index.ts
Normal file
@ -0,0 +1,18 @@
|
||||
export { Card } from './Card';
|
||||
export type { CardProps, Elevation } from './Card';
|
||||
export { Badge } from './Badge';
|
||||
export type { BadgeProps, BadgeTone } from './Badge';
|
||||
export { Textarea } from './Textarea';
|
||||
export type { TextareaProps } from './Textarea';
|
||||
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';
|
||||
20
src/components/rv/CallsView.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import { orderBookingClient } from '../../api/clients';
|
||||
import { ORDER_BOOKING } from '../../api/config';
|
||||
import { RecordView } from './RecordView';
|
||||
import type { WiredRecordViewProps } from './OrdersView';
|
||||
|
||||
/** Calls record view (Calls/Visits workflow). */
|
||||
export function CallsView({ onRowClick, pageSize, headerActions, rowActions, refreshKey }: WiredRecordViewProps) {
|
||||
return (
|
||||
<RecordView
|
||||
client={orderBookingClient}
|
||||
rvUid={ORDER_BOOKING.recordViews.CALLS}
|
||||
title="Calls"
|
||||
onRowClick={onRowClick}
|
||||
pageSize={pageSize}
|
||||
headerActions={headerActions}
|
||||
rowActions={rowActions}
|
||||
refreshKey={refreshKey}
|
||||
/>
|
||||
);
|
||||
}
|
||||
20
src/components/rv/DailyLogsView.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
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, headerActions, rowActions, refreshKey }: WiredRecordViewProps) {
|
||||
return (
|
||||
<RecordView
|
||||
client={dailyReportsClient}
|
||||
rvUid={DAILY_REPORTS.recordViews.DAILY_LOGS}
|
||||
title="Daily Logs"
|
||||
onRowClick={onRowClick}
|
||||
pageSize={pageSize}
|
||||
headerActions={headerActions}
|
||||
rowActions={rowActions}
|
||||
refreshKey={refreshKey}
|
||||
/>
|
||||
);
|
||||
}
|
||||
27
src/components/rv/OrdersView.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import { orderBookingClient } from '../../api/clients';
|
||||
import { ORDER_BOOKING } from '../../api/config';
|
||||
import { RecordView } from './RecordView';
|
||||
|
||||
export interface WiredRecordViewProps {
|
||||
onRowClick?: (row: Record<string, unknown>, index: number) => void;
|
||||
pageSize?: number;
|
||||
headerActions?: React.ReactNode;
|
||||
rowActions?: (row: Record<string, unknown>) => React.ReactNode;
|
||||
refreshKey?: number;
|
||||
}
|
||||
|
||||
/** Orders record view (Order Booking workflow). */
|
||||
export function OrdersView({ onRowClick, pageSize, headerActions, rowActions, refreshKey }: WiredRecordViewProps) {
|
||||
return (
|
||||
<RecordView
|
||||
client={orderBookingClient}
|
||||
rvUid={ORDER_BOOKING.recordViews.ORDERS}
|
||||
title="Orders"
|
||||
onRowClick={onRowClick}
|
||||
pageSize={pageSize}
|
||||
headerActions={headerActions}
|
||||
rowActions={rowActions}
|
||||
refreshKey={refreshKey}
|
||||
/>
|
||||
);
|
||||
}
|
||||
220
src/components/rv/RecordView.tsx
Normal file
@ -0,0 +1,220 @@
|
||||
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';
|
||||
import { StatsTiles } from '../reusable/StatsTiles';
|
||||
import { AnalyticsChart } from '../reusable/AnalyticsChart';
|
||||
import { Select } from '../reusable/Select';
|
||||
|
||||
export interface RecordViewProps {
|
||||
/** Workflow-bound client (see api/clients.ts). */
|
||||
client: ZinoClient;
|
||||
/** rv_template_uid for this view. */
|
||||
rvUid: string;
|
||||
/** Card header title. */
|
||||
title?: string;
|
||||
/** Restrict/order visible columns by field_key. Defaults to all fields. */
|
||||
columns?: string[];
|
||||
/** Rows per page. @default 25 */
|
||||
pageSize?: number;
|
||||
/** Click handler — receives the raw row + index. */
|
||||
onRowClick?: (row: Record<string, unknown>, index: number) => void;
|
||||
/** Extract a stable key for a row. @default row.instance_id ?? index */
|
||||
rowKey?: (row: Record<string, unknown>, index: number) => string;
|
||||
/** Additional actions to render in the header next to search box */
|
||||
headerActions?: React.ReactNode;
|
||||
/** Custom actions to render at the end of each row. */
|
||||
rowActions?: (row: Record<string, unknown>) => React.ReactNode;
|
||||
/** Pass a new value to trigger a refresh. */
|
||||
refreshKey?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = 'Records',
|
||||
columns,
|
||||
pageSize = 50,
|
||||
onRowClick,
|
||||
rowKey,
|
||||
headerActions,
|
||||
rowActions,
|
||||
refreshKey,
|
||||
}: RecordViewProps) {
|
||||
const [page, setPage] = useState(1);
|
||||
const [search, setSearch] = useState('');
|
||||
const [debounced, setDebounced] = useState('');
|
||||
const [activeFilters, setActiveFilters] = useState<Record<string, string>>({});
|
||||
const [resp, setResp] = useState<RecordViewResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Debounce the search box; reset to page 1 whenever the term changes.
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
useEffect(() => {
|
||||
if (timer.current) clearTimeout(timer.current);
|
||||
timer.current = setTimeout(() => {
|
||||
setDebounced(search);
|
||||
setPage(1);
|
||||
}, 300);
|
||||
return () => {
|
||||
if (timer.current) clearTimeout(timer.current);
|
||||
};
|
||||
}, [search]);
|
||||
|
||||
const filtersParam = useMemo(() => {
|
||||
return Object.entries(activeFilters)
|
||||
.filter(([, val]) => val)
|
||||
.map(([key, val]) => ({
|
||||
field_key: key,
|
||||
value: val,
|
||||
data_type: 'string',
|
||||
}));
|
||||
}, [activeFilters]);
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
async function run() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const r = await client.recordView(rvUid, { page, limit: pageSize, search: debounced, filters: filtersParam });
|
||||
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, filtersParam, refreshKey]);
|
||||
|
||||
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;
|
||||
|
||||
const tileValues = resp?.tile_values;
|
||||
|
||||
const chartData = resp?.chart_data;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 w-full">
|
||||
<StatsTiles tiles={tileValues} />
|
||||
|
||||
<AnalyticsChart data={chartData} />
|
||||
|
||||
<Card
|
||||
title={title}
|
||||
action={headerActions}
|
||||
pad={false}
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row sm:flex-wrap items-stretch sm:items-center justify-between gap-3 p-4 border-b border-border-subtle bg-slate-50/50">
|
||||
<div className="flex flex-col sm:flex-row flex-wrap gap-3 w-full sm:w-auto">
|
||||
{resp?.config?.filter_options && Object.entries(resp.config.filter_options).map(([key, options]) => {
|
||||
if (!options || options.length === 0) return null;
|
||||
|
||||
// Find the field in config to get its proper label
|
||||
const fieldDef = resp.config.fields.find(f => f.field_key === key);
|
||||
const label = fieldDef?.output_label || key;
|
||||
|
||||
const opts = options.map(o => ({ value: o, label: o }));
|
||||
return (
|
||||
<Select
|
||||
key={key}
|
||||
value={activeFilters[key] || ''}
|
||||
onChange={(e) => {
|
||||
setActiveFilters(prev => ({ ...prev, [key]: e.target.value }));
|
||||
setPage(1);
|
||||
}}
|
||||
options={[{ value: '', label: `All ${label}` }, ...opts]}
|
||||
className="w-full sm:w-40 shrink-0"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="relative w-full sm:w-[240px] shrink-0">
|
||||
<Search size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-faint pointer-events-none" />
|
||||
<input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search…"
|
||||
className="w-full h-9 rounded-md border border-border-default bg-card pl-9 pr-3 font-sans text-sm text-strong outline-none focus-ring"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<EmptyState title="Couldn’t load records" hint={error} />
|
||||
) : loading && !resp ? (
|
||||
<div className="p-10 flex justify-center">
|
||||
<Spinner label="Loading records…" />
|
||||
</div>
|
||||
) : rows.length === 0 ? (
|
||||
<EmptyState title="No records" hint={debounced ? 'Nothing matches your search.' : undefined} />
|
||||
) : (
|
||||
<div className="flex flex-col gap-3 p-3">
|
||||
{rows.map((row, i) => {
|
||||
const [primary, ...secondary] = fields;
|
||||
return (
|
||||
<div
|
||||
key={rowKey ? rowKey(row, i) : String(row.instance_id ?? i)}
|
||||
onClick={onRowClick ? () => onRowClick(row, i) : undefined}
|
||||
className={cn(
|
||||
'rounded-lg border border-border-subtle bg-card p-4 min-h-[56px] transition-colors duration-150',
|
||||
onRowClick && 'cursor-pointer active:opacity-80',
|
||||
)}
|
||||
>
|
||||
{primary && (
|
||||
<p className="text-md font-semibold text-strong break-words">
|
||||
{formatValue(row[primary.field_key])}
|
||||
</p>
|
||||
)}
|
||||
{secondary.length > 0 && (
|
||||
<div className="mt-1.5 flex flex-col gap-0.5">
|
||||
{secondary.map((f) => (
|
||||
<p key={f.field_key} className="text-xs text-faint break-words">
|
||||
<span className="text-muted">{f.output_label}:</span> {formatValue(row[f.field_key])}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{rowActions && (
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="mt-3 pt-3 border-t border-border-subtle flex justify-end gap-2"
|
||||
>
|
||||
{rowActions(row)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<Pagination page={page} pageSize={pageSize} total={total} onPage={setPage} />
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
20
src/components/rv/StoresView.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
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, headerActions, rowActions, refreshKey }: WiredRecordViewProps & { headerActions?: React.ReactNode }) {
|
||||
return (
|
||||
<RecordView
|
||||
client={storeClient}
|
||||
rvUid={STORE.recordViews.STORE}
|
||||
title="Stores"
|
||||
onRowClick={onRowClick}
|
||||
pageSize={pageSize}
|
||||
headerActions={headerActions}
|
||||
rowActions={rowActions}
|
||||
refreshKey={refreshKey}
|
||||
/>
|
||||
);
|
||||
}
|
||||
7
src/components/rv/index.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export { RecordView } from './RecordView';
|
||||
export type { RecordViewProps } from './RecordView';
|
||||
export { OrdersView } from './OrdersView';
|
||||
export type { WiredRecordViewProps } from './OrdersView';
|
||||
export { CallsView } from './CallsView';
|
||||
export { StoresView } from './StoresView';
|
||||
export { DailyLogsView } from './DailyLogsView';
|
||||
363
src/docs/api/daily_reports.md
Normal file
@ -0,0 +1,363 @@
|
||||
# Daily Reports — Data API Docs
|
||||
|
||||
**App ID:** `434`
|
||||
**Version UUID:** `d4d4c738-bf74-4b44-a297-9a6653458f3a`
|
||||
**Workflow UUID:** `c420dc04-2ba3-488e-902d-66bc9a2032cd`
|
||||
|
||||
## Base URLs
|
||||
|
||||
| Key | URL |
|
||||
| ---- | ---------------------------- |
|
||||
| core | `https://sandbox.getzino.in` |
|
||||
| view | `https://sandbox.getzino.in` |
|
||||
| user | `https://sandbox.getzino.in` |
|
||||
|
||||
All requests require header `Authorization: Bearer <JWT_TOKEN>` (user session JWT from login).
|
||||
|
||||
---
|
||||
|
||||
## 1. Activity Submission
|
||||
|
||||
Calls that create an instance or submit an activity's form. Init = `/start`; subsequent = `/activity`. Upload/OCR fire during fill for file-bearing fields.
|
||||
|
||||
### 1.1 Init Activity (init)
|
||||
|
||||
Create a new workflow instance and run the init activity.
|
||||
|
||||
- **Method:** `POST`
|
||||
- **Path:** `/app/434/start` (base: core)
|
||||
- **Content-Type:** `application/json`
|
||||
|
||||
**Request body**
|
||||
|
||||
```json
|
||||
{
|
||||
"activity_id": "ac04a444-9b96-463d-bc91-447fa80b028f",
|
||||
"data": {
|
||||
"date": "2024-01-15",
|
||||
"day_plan_notes": "string_value",
|
||||
"field_1": "09:30",
|
||||
"route_code": "option_value",
|
||||
"sales_officer_name": "string_value",
|
||||
"store_image": "<IMAGE_UPLOAD>",
|
||||
"sub_route": "option_value"
|
||||
},
|
||||
"version": 1,
|
||||
"workflow_uuid": "c420dc04-2ba3-488e-902d-66bc9a2032cd"
|
||||
}
|
||||
```
|
||||
|
||||
**Request fields**
|
||||
|
||||
| Key | Type | Required | Note |
|
||||
| ------------------ | -------- | -------- | ----------------------------- |
|
||||
| workflow_uuid | string | ✅ | Clone-portable workflow UID. |
|
||||
| activity_id | string | ✅ | The init activity UID. |
|
||||
| version | int | ❌ | Deployed workflow version. |
|
||||
| data | object | ❌ | Field values keyed by field id. |
|
||||
| sales_officer_name | app_user | ❌ | "Sales Officer Name" (app_user) |
|
||||
| date | date | ❌ | "Date" (date) |
|
||||
| field_1 | time | ❌ | "Time" (time) |
|
||||
| route_code | select | ❌ | "Route Code" (select) |
|
||||
| sub_route | select | ❌ | "Sub Route" (select) |
|
||||
| store_image | image | ❌ | "Upload Store Image" (image) |
|
||||
| day_plan_notes | longtext | ❌ | "Day Plan (notes)" (longtext) |
|
||||
|
||||
### 1.2 Punch Out
|
||||
|
||||
Advance an existing instance through this activity (normal submission).
|
||||
|
||||
- **Method:** `POST`
|
||||
- **Path:** `/app/434/activity` (base: core)
|
||||
- **Content-Type:** `application/json`
|
||||
|
||||
**Request body**
|
||||
|
||||
```json
|
||||
{
|
||||
"activity_id": "37c218fc-fd91-41d1-9964-fcaeade54e74",
|
||||
"data": {
|
||||
"eod_notes_remarks": "string_value",
|
||||
"total_non_productive_calls": 123,
|
||||
"total_productive_calls": 123
|
||||
},
|
||||
"instance_id": "<INSTANCE_ID>",
|
||||
"workflow_uuid": "c420dc04-2ba3-488e-902d-66bc9a2032cd"
|
||||
}
|
||||
```
|
||||
|
||||
**Request fields**
|
||||
|
||||
| Key | Type | Required | Note |
|
||||
| -------------------------- | -------- | -------- | ------------------------------------ |
|
||||
| workflow_uuid | string | ✅ | Clone-portable workflow UID. |
|
||||
| activity_id | string | ✅ | This activity's UID. |
|
||||
| instance_id | int64 | ✅ | Target instance (runtime value). |
|
||||
| data | object | ❌ | Field values keyed by field id. |
|
||||
| total_productive_calls | number | ❌ | "Total Productive Calls" (number) |
|
||||
| total_non_productive_calls | number | ❌ | "Total Non Productive Calls" (number) |
|
||||
| eod_notes_remarks | longtext | ❌ | "EOD Notes (Remarks)" (longtext) |
|
||||
|
||||
### 1.3 Upload file
|
||||
|
||||
Upload a file/image during form fill; returns `blob_path` that replaces the File in the submit payload.
|
||||
|
||||
- **Method:** `POST`
|
||||
- **Path:** `/app/434/upload` (base: core)
|
||||
- **Content-Type:** `multipart/form-data` (browser sets the boundary automatically)
|
||||
|
||||
**Form fields**
|
||||
|
||||
| Key | Type | Required | Note |
|
||||
| ------------- | ------ | -------- | ------------------------------------------- |
|
||||
| file | binary | ✅ | The file contents. |
|
||||
| workflow_uuid | string | ✅ | Fixed: `c420dc04-2ba3-488e-902d-66bc9a2032cd`. |
|
||||
| activity_id | string | ✅ | |
|
||||
| field_id | string | ✅ | |
|
||||
| column_id | string | ❌ | Only for grid-cell file columns. |
|
||||
| version_uuid | string | ❌ | |
|
||||
| instance_id | int64 | ❌ | Runtime value when editing an instance. |
|
||||
|
||||
**Response body**
|
||||
|
||||
```json
|
||||
{
|
||||
"blob_path": "12/a1b2c3.png",
|
||||
"mime_type": "image/png",
|
||||
"original_name": "photo.png",
|
||||
"size_bytes": 20480,
|
||||
"url": "<STORAGE_URL>"
|
||||
}
|
||||
```
|
||||
|
||||
| Key | Type | Note |
|
||||
| ------------- | ------ | --------------------------------------------- |
|
||||
| blob_path | string | Storage path that replaces the File in `data[field_id]`. |
|
||||
| original_name | string | |
|
||||
| mime_type | string | |
|
||||
| size_bytes | int64 | |
|
||||
| url | string | |
|
||||
|
||||
### Common submission response
|
||||
|
||||
Applies to Init Activity and Punch Out.
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {},
|
||||
"instance_id": 1024,
|
||||
"message": "Activity completed successfully",
|
||||
"status_code": 200,
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
| Key | Type | Note |
|
||||
| ----------- | ------ | --------------------------------------------------------------- |
|
||||
| success | bool | Whether the activity ran. |
|
||||
| status_code | int | HTTP status; mirrors a Response node if the trigger graph has one. |
|
||||
| message | string | Human-readable result; from a Response node when present. |
|
||||
| data | object | Response-node payload; empty object by default. |
|
||||
| instance_id | int64 | The workflow instance affected/created. |
|
||||
|
||||
---
|
||||
|
||||
## 2. View APIs
|
||||
|
||||
Read endpoints that power record/detail/activity/chart views, segregated by source type with each view's UI name.
|
||||
|
||||
### 2.1 Record Views
|
||||
|
||||
Paginated records + tiles + charts.
|
||||
|
||||
- **Method:** `POST`
|
||||
- **Path:** `/app/434/view/recordview` (base: view)
|
||||
- **Content-Type:** `application/json`
|
||||
|
||||
| UI Name | rv_template_uid |
|
||||
| ---------- | -------------------------------------- |
|
||||
| Daily Logs | `269da629-1701-477a-a37b-a78bb056d4b3` |
|
||||
|
||||
**Request body**
|
||||
|
||||
```json
|
||||
{
|
||||
"params": {},
|
||||
"preset_alias": "",
|
||||
"rv_template_uid": "269da629-1701-477a-a37b-a78bb056d4b3",
|
||||
"search_query": {
|
||||
"filters": [],
|
||||
"limit": 25,
|
||||
"page": 1,
|
||||
"search": "",
|
||||
"sort_by": "",
|
||||
"sort_dir": "desc"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Request fields**
|
||||
|
||||
| Key | Type | Required | Note |
|
||||
| --------------------- | ------ | -------- | ----------------------------------------------------------- |
|
||||
| rv_template_uid | string | ✅ | This view's template UID. |
|
||||
| preset_alias | string | ❌ | Optional; selects a named prefilter preset on this view. |
|
||||
| params | object | ❌ | Values for declared input params (`${input.<key>}` refs). |
|
||||
| search_query.page | int | ❌ | 1-based page. |
|
||||
| search_query.limit | int | ❌ | Default 25, max 200. |
|
||||
| search_query.sort_by | string | ❌ | `field_key` \| `created_at` \| `updated_at` \| `instance_id`. |
|
||||
| search_query.sort_dir | string | ❌ | `asc` \| `desc`. |
|
||||
| search_query.search | string | ❌ | ILIKE term across searchable fields. |
|
||||
| search_query.filters | array | ❌ | `[{field_key, value, value2?, data_type}]`. |
|
||||
|
||||
**Response body**
|
||||
|
||||
```json
|
||||
{
|
||||
"chart_data": [],
|
||||
"data": [],
|
||||
"pagination": { "limit": 25, "page": 1, "total_records": 0 },
|
||||
"tile_values": {}
|
||||
}
|
||||
```
|
||||
|
||||
| Key | Type | Note |
|
||||
| ----------- | ------ | ------------------------------- |
|
||||
| data | array | Companion-table rows. |
|
||||
| tile_values | object | |
|
||||
| chart_data | array | |
|
||||
| pagination | object | `{page, limit, total_records}`. |
|
||||
|
||||
### 2.2 Detail Views
|
||||
|
||||
Single-instance detail record.
|
||||
|
||||
- **Method:** `GET`
|
||||
- **Base:** view
|
||||
|
||||
| UI Name | Path |
|
||||
| ---------- | ---------------------------------------------------------------------------- |
|
||||
| Daily Logs | `/app/434/view/detailview/26b3c6de-a1d7-4f71-be88-7e4929932b0b?instance_id=<INSTANCE_ID>` |
|
||||
|
||||
**Request fields**
|
||||
|
||||
| Key | Type | Required | Note |
|
||||
| ----------- | ----- | -------- | ----------------------------------------------------------------- |
|
||||
| instance_id | int64 | ✅ | Query param (runtime value); some DVs use declared extra_params. |
|
||||
|
||||
**Response body**
|
||||
|
||||
```json
|
||||
{ "config": {}, "data": {} }
|
||||
```
|
||||
|
||||
| Key | Type | Note |
|
||||
| ------ | ------ | ----------------- |
|
||||
| config | object | |
|
||||
| data | object | `field_key→value`. |
|
||||
|
||||
---
|
||||
|
||||
## 3. In-form Activity Helper APIs
|
||||
|
||||
Calls a form makes while being filled, segregated per activity: lookup, workflow-lookup, app-user, dataset options, field actions, and peer-instance prefetch.
|
||||
|
||||
### Activity: Init Activity (`ac04a444-9b96-463d-bc91-447fa80b028f`)
|
||||
|
||||
#### App User — Sales Officer Name
|
||||
|
||||
App-user dropdown: open / search.
|
||||
|
||||
- **Method:** `POST`
|
||||
- **Path:** `/app/434/app-user/records` (base: core)
|
||||
- **Content-Type:** `application/json`
|
||||
|
||||
**Request body**
|
||||
|
||||
```json
|
||||
{
|
||||
"activity_id": "ac04a444-9b96-463d-bc91-447fa80b028f",
|
||||
"field_id": "sales_officer_name",
|
||||
"form_data": {},
|
||||
"limit": 50,
|
||||
"offset": 0,
|
||||
"search": "",
|
||||
"workflow_uuid": "c420dc04-2ba3-488e-902d-66bc9a2032cd"
|
||||
}
|
||||
```
|
||||
|
||||
**Request fields**
|
||||
|
||||
| Key | Type | Required | Note |
|
||||
| ------------- | ------ | -------- | ----------------------------------------------- |
|
||||
| workflow_uuid | string | ✅ | Fixed: `c420dc04-2ba3-488e-902d-66bc9a2032cd`. |
|
||||
| activity_id | string | ✅ | |
|
||||
| field_id | string | ✅ | |
|
||||
| search | string | ❌ | Search term. |
|
||||
| limit | int | ❌ | Default 50, max 200. |
|
||||
| offset | int | ❌ | |
|
||||
| filters | array | ❌ | `[{column, operator, value}]`. |
|
||||
| form_data | object | ❌ | Current form values for filter_options. |
|
||||
| version_uuid | string | ❌ | Pins filter_options to the deployed version. |
|
||||
|
||||
**Response body**
|
||||
|
||||
```json
|
||||
{ "limit": 50, "offset": 0, "records": [], "total": 0 }
|
||||
```
|
||||
|
||||
| Key | Type | Note |
|
||||
| ------- | ----- | ------------------------------------------------- |
|
||||
| records | array | For dataset-options this is `options:[{value,label}]`. |
|
||||
| total | int | |
|
||||
| limit | int | |
|
||||
| offset | int | |
|
||||
|
||||
#### Dataset options — Route Code
|
||||
|
||||
Dataset-backed options: mount + dependency change.
|
||||
|
||||
- **Method:** `POST`
|
||||
- **Path:** `/app/434/dataset-options` (base: core)
|
||||
- **Content-Type:** `application/json`
|
||||
|
||||
**Request body**
|
||||
|
||||
```json
|
||||
{
|
||||
"activity_id": "ac04a444-9b96-463d-bc91-447fa80b028f",
|
||||
"field_id": "route_code",
|
||||
"form_data": {},
|
||||
"limit": 50,
|
||||
"offset": 0,
|
||||
"search": "",
|
||||
"workflow_uuid": "c420dc04-2ba3-488e-902d-66bc9a2032cd"
|
||||
}
|
||||
```
|
||||
|
||||
**Request fields**
|
||||
|
||||
| Key | Type | Required | Note |
|
||||
| ------------- | ------ | -------- | ----------------------------------------------- |
|
||||
| workflow_uuid | string | ✅ | Fixed: `c420dc04-2ba3-488e-902d-66bc9a2032cd`. |
|
||||
| activity_id | string | ✅ | |
|
||||
| field_id | string | ✅ | |
|
||||
| search | string | ❌ | Search term. |
|
||||
| limit | int | ❌ | Default 50, max 200. |
|
||||
| offset | int | ❌ | |
|
||||
| filters | array | ❌ | `[{column, operator, value}]`. |
|
||||
| form_data | object | ❌ | Current form values for filter_options. |
|
||||
| version_uuid | string | ❌ | Pins filter_options to the deployed version. |
|
||||
|
||||
**Response body**
|
||||
|
||||
```json
|
||||
{ "limit": 50, "offset": 0, "records": [], "total": 0 }
|
||||
```
|
||||
|
||||
| Key | Type | Note |
|
||||
| ------- | ----- | ------------------------------------------------- |
|
||||
| records | array | For dataset-options this is `options:[{value,label}]`. |
|
||||
| total | int | |
|
||||
| limit | int | |
|
||||
| offset | int | |
|
||||
456
src/docs/api/order_booking.md
Normal file
@ -0,0 +1,456 @@
|
||||
# Order Booking — Data API Docs
|
||||
|
||||
**App ID:** `434`
|
||||
**Version UUID:** `f19b2146-88e5-448a-9a51-e517ebce680a`
|
||||
**Workflow UUID:** `b5a21627-9422-40cf-b7f5-31fa070bf42f`
|
||||
|
||||
## Base URLs
|
||||
|
||||
| Key | URL |
|
||||
| ---- | ---------------------------- |
|
||||
| core | `https://sandbox.getzino.in` |
|
||||
| view | `https://sandbox.getzino.in` |
|
||||
| user | `https://sandbox.getzino.in` |
|
||||
|
||||
All requests require header `Authorization: Bearer <JWT_TOKEN>` (user session JWT from login).
|
||||
|
||||
---
|
||||
|
||||
## 1. Activity Submission
|
||||
|
||||
Calls that create an instance or submit an activity's form. Init = `/start`; subsequent = `/activity`. Upload/OCR fire during fill for file-bearing fields.
|
||||
|
||||
### 1.1 Log Visit (init)
|
||||
|
||||
Create a new workflow instance and run the init activity.
|
||||
|
||||
- **Method:** `POST`
|
||||
- **Path:** `/app/434/start` (base: core)
|
||||
- **Content-Type:** `application/json`
|
||||
|
||||
**Request body**
|
||||
|
||||
```json
|
||||
{
|
||||
"activity_id": "42b7f47d-96f0-4289-a6d9-38f455ad57dd",
|
||||
"data": {
|
||||
"date_of_visit": "2024-01-15",
|
||||
"select_store": "string_value",
|
||||
"time_of_visit": "09:30",
|
||||
"upload_image": "<IMAGE_UPLOAD>"
|
||||
},
|
||||
"version": 1,
|
||||
"workflow_uuid": "b5a21627-9422-40cf-b7f5-31fa070bf42f"
|
||||
}
|
||||
```
|
||||
|
||||
**Request fields**
|
||||
|
||||
| Key | Type | Required | Note |
|
||||
| -------------- | --------- | -------- | ----------------------------- |
|
||||
| workflow_uuid | string | ✅ | Clone-portable workflow UID. |
|
||||
| activity_id | string | ✅ | The init activity UID. |
|
||||
| version | int | ❌ | Deployed workflow version. |
|
||||
| data | object | ❌ | Field values keyed by field id. |
|
||||
| select_store | wf_lookup | ❌ | "Select Store" (wf_lookup) |
|
||||
| date_of_visit | date | ❌ | "Date of Visit" (date) |
|
||||
| time_of_visit | time | ❌ | "Time of Visit" (time) |
|
||||
| upload_image | image | ❌ | "Upload Image" (image) |
|
||||
|
||||
### 1.2 Place Order
|
||||
|
||||
Advance an existing instance through this activity (normal submission).
|
||||
|
||||
- **Method:** `POST`
|
||||
- **Path:** `/app/434/activity` (base: core)
|
||||
- **Content-Type:** `application/json`
|
||||
|
||||
**Request body**
|
||||
|
||||
```json
|
||||
{
|
||||
"activity_id": "f66403d7-e31f-4d64-bcfd-ff5485aec8b8",
|
||||
"data": {
|
||||
"date_of_order": "2024-01-15",
|
||||
"order_details": [
|
||||
{
|
||||
"bags": 123,
|
||||
"br_code": "string_value",
|
||||
"product_category": "option_value",
|
||||
"product_name": "option_value",
|
||||
"sku": "string_value",
|
||||
"sku_code": "string_value"
|
||||
}
|
||||
],
|
||||
"total_bags": 123,
|
||||
"total_kgs": 123
|
||||
},
|
||||
"instance_id": "<INSTANCE_ID>",
|
||||
"workflow_uuid": "b5a21627-9422-40cf-b7f5-31fa070bf42f"
|
||||
}
|
||||
```
|
||||
|
||||
**Request fields**
|
||||
|
||||
| Key | Type | Required | Note |
|
||||
| ------------- | ------ | -------- | ----------------------------- |
|
||||
| workflow_uuid | string | ✅ | Clone-portable workflow UID. |
|
||||
| activity_id | string | ✅ | This activity's UID. |
|
||||
| instance_id | int64 | ✅ | Target instance (runtime value). |
|
||||
| data | object | ❌ | Field values keyed by field id. |
|
||||
| date_of_order | date | ❌ | "Date of Order" (date) |
|
||||
| order_details | grid | ❌ | "Order Details" (grid) |
|
||||
| total_bags | number | ❌ | "Total bags" (number) |
|
||||
| total_kgs | number | ❌ | "Total Kgs" (number) |
|
||||
|
||||
### 1.3 Productivity of Visit
|
||||
|
||||
Advance an existing instance through this activity (normal submission).
|
||||
|
||||
- **Method:** `POST`
|
||||
- **Path:** `/app/434/activity` (base: core)
|
||||
- **Content-Type:** `application/json`
|
||||
|
||||
**Request body**
|
||||
|
||||
```json
|
||||
{
|
||||
"activity_id": "11bd10f9-a001-470e-867f-33dee8eabe4b",
|
||||
"data": {
|
||||
"action": "string_value",
|
||||
"is_productive_call": "string_value",
|
||||
"order_received_channel": "on_call",
|
||||
"remarks": "string_value",
|
||||
"store_status": "opened"
|
||||
},
|
||||
"instance_id": "<INSTANCE_ID>",
|
||||
"workflow_uuid": "b5a21627-9422-40cf-b7f5-31fa070bf42f"
|
||||
}
|
||||
```
|
||||
|
||||
**Request fields**
|
||||
|
||||
| Key | Type | Required | Note |
|
||||
| ---------------------- | -------- | -------- | -------------------------------- |
|
||||
| workflow_uuid | string | ✅ | Clone-portable workflow UID. |
|
||||
| activity_id | string | ✅ | This activity's UID. |
|
||||
| instance_id | int64 | ✅ | Target instance (runtime value). |
|
||||
| data | object | ❌ | Field values keyed by field id. |
|
||||
| is_productive_call | radio | ❌ | "Is Productive Call?" (radio) |
|
||||
| store_status | select | ❌ | "Store Status" (select) |
|
||||
| order_received_channel | select | ❌ | "Order Received Channel" (select) |
|
||||
| remarks | longtext | ❌ | "Remarks" (longtext) |
|
||||
| action | radio | ❌ | "Action" (radio) |
|
||||
|
||||
### 1.4 Potential Mining
|
||||
|
||||
Advance an existing instance through this activity (normal submission).
|
||||
|
||||
- **Method:** `POST`
|
||||
- **Path:** `/app/434/activity` (base: core)
|
||||
- **Content-Type:** `application/json`
|
||||
|
||||
**Request body**
|
||||
|
||||
```json
|
||||
{
|
||||
"activity_id": "af8ac8df-d868-4f7d-88b2-123955d69c56",
|
||||
"data": {
|
||||
"potential": [
|
||||
{
|
||||
"actual_potential": 123,
|
||||
"difference": 123,
|
||||
"potential_remarks": "string_value",
|
||||
"product_category_": "option_value",
|
||||
"reason": "option_value",
|
||||
"total_ordered": 123
|
||||
}
|
||||
]
|
||||
},
|
||||
"instance_id": "<INSTANCE_ID>",
|
||||
"workflow_uuid": "b5a21627-9422-40cf-b7f5-31fa070bf42f"
|
||||
}
|
||||
```
|
||||
|
||||
**Request fields**
|
||||
|
||||
| Key | Type | Required | Note |
|
||||
| ------------- | ------ | -------- | -------------------------------- |
|
||||
| workflow_uuid | string | ✅ | Clone-portable workflow UID. |
|
||||
| activity_id | string | ✅ | This activity's UID. |
|
||||
| instance_id | int64 | ✅ | Target instance (runtime value). |
|
||||
| data | object | ❌ | Field values keyed by field id. |
|
||||
| potential | grid | ❌ | "Potential" (grid) |
|
||||
|
||||
### 1.5 Close Order
|
||||
|
||||
Advance an existing instance through this activity (normal submission).
|
||||
|
||||
- **Method:** `POST`
|
||||
- **Path:** `/app/434/activity` (base: core)
|
||||
- **Content-Type:** `application/json`
|
||||
|
||||
**Request body**
|
||||
|
||||
```json
|
||||
{
|
||||
"activity_id": "8580d7a2-5d31-40ab-b7ea-2a38d144e59a",
|
||||
"data": {
|
||||
"remarks_2": "string_value"
|
||||
},
|
||||
"instance_id": "<INSTANCE_ID>",
|
||||
"workflow_uuid": "b5a21627-9422-40cf-b7f5-31fa070bf42f"
|
||||
}
|
||||
```
|
||||
|
||||
**Request fields**
|
||||
|
||||
| Key | Type | Required | Note |
|
||||
| ------------- | -------- | -------- | -------------------------------- |
|
||||
| workflow_uuid | string | ✅ | Clone-portable workflow UID. |
|
||||
| activity_id | string | ✅ | This activity's UID. |
|
||||
| instance_id | int64 | ✅ | Target instance (runtime value). |
|
||||
| data | object | ❌ | Field values keyed by field id. |
|
||||
| remarks_2 | longtext | ❌ | "Remarks" (longtext) |
|
||||
|
||||
### 1.6 Upload file
|
||||
|
||||
Upload a file/image during form fill; returns `blob_path` that replaces the File in the submit payload.
|
||||
|
||||
- **Method:** `POST`
|
||||
- **Path:** `/app/434/upload` (base: core)
|
||||
- **Content-Type:** `multipart/form-data` (browser sets the boundary automatically)
|
||||
|
||||
**Form fields**
|
||||
|
||||
| Key | Type | Required | Note |
|
||||
| ------------- | ------ | -------- | ------------------------------------------- |
|
||||
| file | binary | ✅ | The file contents. |
|
||||
| workflow_uuid | string | ✅ | Fixed: `b5a21627-9422-40cf-b7f5-31fa070bf42f`. |
|
||||
| activity_id | string | ✅ | |
|
||||
| field_id | string | ✅ | |
|
||||
| column_id | string | ❌ | Only for grid-cell file columns. |
|
||||
| version_uuid | string | ❌ | |
|
||||
| instance_id | int64 | ❌ | Runtime value when editing an instance. |
|
||||
|
||||
**Response body**
|
||||
|
||||
```json
|
||||
{
|
||||
"blob_path": "12/a1b2c3.png",
|
||||
"mime_type": "image/png",
|
||||
"original_name": "photo.png",
|
||||
"size_bytes": 20480,
|
||||
"url": "<STORAGE_URL>"
|
||||
}
|
||||
```
|
||||
|
||||
| Key | Type | Note |
|
||||
| ------------- | ------ | --------------------------------------------- |
|
||||
| blob_path | string | Storage path that replaces the File in `data[field_id]`. |
|
||||
| original_name | string | |
|
||||
| mime_type | string | |
|
||||
| size_bytes | int64 | |
|
||||
| url | string | |
|
||||
|
||||
### Common submission response
|
||||
|
||||
Applies to Log Visit, Place Order, Productivity of Visit, Potential Mining, Close Order.
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {},
|
||||
"instance_id": 1024,
|
||||
"message": "Activity completed successfully",
|
||||
"status_code": 200,
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
| Key | Type | Note |
|
||||
| ----------- | ------ | --------------------------------------------------------------- |
|
||||
| success | bool | Whether the activity ran. |
|
||||
| status_code | int | HTTP status; mirrors a Response node if the trigger graph has one. |
|
||||
| message | string | Human-readable result; from a Response node when present. |
|
||||
| data | object | Response-node payload; empty object by default. |
|
||||
| instance_id | int64 | The workflow instance affected/created. |
|
||||
|
||||
---
|
||||
|
||||
## 2. View APIs
|
||||
|
||||
Read endpoints that power record/detail/activity/chart views, segregated by source type with each view's UI name.
|
||||
|
||||
### 2.1 Record Views
|
||||
|
||||
Paginated records + tiles + charts.
|
||||
|
||||
- **Method:** `POST`
|
||||
- **Path:** `/app/434/view/recordview` (base: view)
|
||||
- **Content-Type:** `application/json`
|
||||
|
||||
| UI Name | rv_template_uid |
|
||||
| ------- | -------------------------------------- |
|
||||
| Orders | `1e424561-9a27-44f5-9b68-64d63296d837` |
|
||||
| Calls | `b4d7a710-24e7-416d-885a-31fd1e727aab` |
|
||||
|
||||
**Request body**
|
||||
|
||||
```json
|
||||
{
|
||||
"params": {},
|
||||
"preset_alias": "",
|
||||
"rv_template_uid": "1e424561-9a27-44f5-9b68-64d63296d837",
|
||||
"search_query": {
|
||||
"filters": [],
|
||||
"limit": 25,
|
||||
"page": 1,
|
||||
"search": "",
|
||||
"sort_by": "",
|
||||
"sort_dir": "desc"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Request fields**
|
||||
|
||||
| Key | Type | Required | Note |
|
||||
| --------------------- | ------ | -------- | ----------------------------------------------------------- |
|
||||
| rv_template_uid | string | ✅ | This view's template UID. |
|
||||
| preset_alias | string | ❌ | Optional; selects a named prefilter preset on this view. |
|
||||
| params | object | ❌ | Values for declared input params (`${input.<key>}` refs). |
|
||||
| search_query.page | int | ❌ | 1-based page. |
|
||||
| search_query.limit | int | ❌ | Default 25, max 200. |
|
||||
| search_query.sort_by | string | ❌ | `field_key` \| `created_at` \| `updated_at` \| `instance_id`. |
|
||||
| search_query.sort_dir | string | ❌ | `asc` \| `desc`. |
|
||||
| search_query.search | string | ❌ | ILIKE term across searchable fields. |
|
||||
| search_query.filters | array | ❌ | `[{field_key, value, value2?, data_type}]`. |
|
||||
|
||||
**Response body**
|
||||
|
||||
```json
|
||||
{
|
||||
"chart_data": [],
|
||||
"data": [],
|
||||
"pagination": { "limit": 25, "page": 1, "total_records": 0 },
|
||||
"tile_values": {}
|
||||
}
|
||||
```
|
||||
|
||||
| Key | Type | Note |
|
||||
| ----------- | ------ | ------------------------------- |
|
||||
| data | array | Companion-table rows. |
|
||||
| tile_values | object | |
|
||||
| chart_data | array | |
|
||||
| pagination | object | `{page, limit, total_records}`. |
|
||||
|
||||
### 2.2 Detail Views
|
||||
|
||||
Single-instance detail record.
|
||||
|
||||
- **Method:** `GET`
|
||||
- **Base:** view
|
||||
|
||||
| UI Name | Path |
|
||||
| ------- | ---------------------------------------------------------------------------- |
|
||||
| Orders | `/app/434/view/detailview/0804c6c3-6cf9-4050-94bb-fa48dd5de87d?instance_id=<INSTANCE_ID>` |
|
||||
| Calls | `/app/434/view/detailview/09a32bfe-9e43-4e0f-bb7d-db89c8a2557c?instance_id=<INSTANCE_ID>` |
|
||||
|
||||
**Request fields**
|
||||
|
||||
| Key | Type | Required | Note |
|
||||
| ----------- | ----- | -------- | ----------------------------------------------------------------- |
|
||||
| instance_id | int64 | ✅ | Query param (runtime value); some DVs use declared extra_params. |
|
||||
|
||||
**Response body**
|
||||
|
||||
```json
|
||||
{ "config": {}, "data": {} }
|
||||
```
|
||||
|
||||
| Key | Type | Note |
|
||||
| ------ | ------ | ----------------- |
|
||||
| config | object | |
|
||||
| data | object | `field_key→value`. |
|
||||
|
||||
---
|
||||
|
||||
## 3. In-form Activity Helper APIs
|
||||
|
||||
Calls a form makes while being filled, segregated per activity: lookup, workflow-lookup, app-user, dataset options, field actions, and peer-instance prefetch.
|
||||
|
||||
### Activity: Log Visit (`42b7f47d-96f0-4289-a6d9-38f455ad57dd`)
|
||||
|
||||
#### WF Lookup — Select Store
|
||||
|
||||
Workflow-lookup dropdown: open / search / dependency change.
|
||||
|
||||
- **Method:** `POST`
|
||||
- **Path:** `/app/434/wf-lookup/records` (base: core)
|
||||
- **Content-Type:** `application/json`
|
||||
|
||||
**Request body**
|
||||
|
||||
```json
|
||||
{
|
||||
"activity_id": "42b7f47d-96f0-4289-a6d9-38f455ad57dd",
|
||||
"field_id": "select_store",
|
||||
"form_data": {},
|
||||
"limit": 50,
|
||||
"offset": 0,
|
||||
"search": "",
|
||||
"workflow_uuid": "b5a21627-9422-40cf-b7f5-31fa070bf42f"
|
||||
}
|
||||
```
|
||||
|
||||
**Request fields**
|
||||
|
||||
| Key | Type | Required | Note |
|
||||
| ------------- | ------ | -------- | ----------------------------------------------- |
|
||||
| workflow_uuid | string | ✅ | Fixed: `b5a21627-9422-40cf-b7f5-31fa070bf42f`. |
|
||||
| activity_id | string | ✅ | |
|
||||
| field_id | string | ✅ | |
|
||||
| search | string | ❌ | Search term. |
|
||||
| limit | int | ❌ | Default 50, max 200. |
|
||||
| offset | int | ❌ | |
|
||||
| filters | array | ❌ | `[{column, operator, value}]`. |
|
||||
| form_data | object | ❌ | Current form values for filter_options. |
|
||||
| version_uuid | string | ❌ | Pins filter_options to the deployed version. |
|
||||
|
||||
**Response body**
|
||||
|
||||
```json
|
||||
{ "limit": 50, "offset": 0, "records": [], "total": 0 }
|
||||
```
|
||||
|
||||
| Key | Type | Note |
|
||||
| ------- | ----- | ------------------------------------------------- |
|
||||
| records | array | For dataset-options this is `options:[{value,label}]`. |
|
||||
| total | int | |
|
||||
| limit | int | |
|
||||
| offset | int | |
|
||||
|
||||
#### Peer instance data
|
||||
|
||||
Form-open prefetch of a peer instance referenced by field_rules / compute_value chains.
|
||||
|
||||
- **Method:** `GET`
|
||||
- **Path:** `/app/434/view/instance-data?workflow_id=<PEER_WORKFLOW_ID>&instance_id=<INSTANCE_ID>` (base: view)
|
||||
|
||||
**Request fields**
|
||||
|
||||
| Key | Type | Required | Note |
|
||||
| ----------- | ----- | -------- | ------------------------------------------------------------------- |
|
||||
| workflow_id | int64 | ✅ | Query param; the peer/target workflow's runtime id (not this one). |
|
||||
| instance_id | int64 | ✅ | Query param; peer instance id. |
|
||||
|
||||
**Response body**
|
||||
|
||||
```json
|
||||
{ "instance": {}, "workflow_fields": {} }
|
||||
```
|
||||
|
||||
| Key | Type | Note |
|
||||
| --------------- | ------ | ---- |
|
||||
| instance | object | |
|
||||
| workflow_fields | object | |
|
||||
386
src/docs/api/store.md
Normal file
@ -0,0 +1,386 @@
|
||||
# Store — Data API Docs
|
||||
|
||||
**App ID:** `434`
|
||||
**Version UUID:** `ced1fb5b-c6a4-4305-b85d-3c263d9d618e`
|
||||
**Workflow UUID:** `2f827d27-b7a4-4ed9-9336-879fa46dbcba`
|
||||
|
||||
## Base URLs
|
||||
|
||||
| Key | URL |
|
||||
| ---- | ---------------------------- |
|
||||
| core | `https://sandbox.getzino.in` |
|
||||
| view | `https://sandbox.getzino.in` |
|
||||
| user | `https://sandbox.getzino.in` |
|
||||
|
||||
All requests require header `Authorization: Bearer <JWT_TOKEN>` (user session JWT from login).
|
||||
|
||||
---
|
||||
|
||||
## 1. Activity Submission
|
||||
|
||||
Calls that create an instance or submit an activity's form. Init = `/start`; subsequent = `/activity`. Upload/OCR fire during fill for file-bearing fields.
|
||||
|
||||
### 1.1 Init Activity (init)
|
||||
|
||||
Create a new workflow instance and run the init activity.
|
||||
|
||||
- **Method:** `POST`
|
||||
- **Path:** `/app/434/start` (base: core)
|
||||
- **Content-Type:** `application/json`
|
||||
|
||||
**Request body**
|
||||
|
||||
```json
|
||||
{
|
||||
"activity_id": "8626a77f-9e70-448e-8ac9-2c51eae5015b",
|
||||
"data": {
|
||||
"area_2": "string_value",
|
||||
"business_name_2": "string_value",
|
||||
"complete_address_2": "string_value",
|
||||
"distributor_email_2": "user@example.com",
|
||||
"distributor_name_2": "option_value",
|
||||
"distributor_owner_name_2": "string_value",
|
||||
"distributor_phone_number_2": {
|
||||
"dial_code": "+1",
|
||||
"phone": "2345678901",
|
||||
"phone_with_dial_code": "+12345678901"
|
||||
},
|
||||
"email_2": "user@example.com",
|
||||
"notes_2": "string_value",
|
||||
"owner_name_2": "string_value",
|
||||
"phone_number_2": {
|
||||
"dial_code": "+1",
|
||||
"phone": "2345678901",
|
||||
"phone_with_dial_code": "+12345678901"
|
||||
},
|
||||
"pin_code_2": 123,
|
||||
"potential_2": [
|
||||
{ "product_category_1": "option_value", "quantity_1": 123 }
|
||||
],
|
||||
"route_code_2": "option_value",
|
||||
"route_name_2": "option_value",
|
||||
"store_image_2": "<IMAGE_UPLOAD>",
|
||||
"store_location_2": "string_value",
|
||||
"sub_route_2": "option_value"
|
||||
},
|
||||
"version": 1,
|
||||
"workflow_uuid": "2f827d27-b7a4-4ed9-9336-879fa46dbcba"
|
||||
}
|
||||
```
|
||||
|
||||
**Request fields**
|
||||
|
||||
| Key | Type | Required | Note |
|
||||
| -------------------------- | ----------- | -------- | -------------------------------- |
|
||||
| workflow_uuid | string | ✅ | Clone-portable workflow UID. |
|
||||
| activity_id | string | ✅ | The init activity UID. |
|
||||
| version | int | ❌ | Deployed workflow version. |
|
||||
| data | object | ❌ | Field values keyed by field id. |
|
||||
| business_name_2 | text | ❌ | "Business Name" (text) |
|
||||
| area_2 | text | ❌ | "Area" (text) |
|
||||
| complete_address_2 | longtext | ❌ | "Complete Address" (longtext) |
|
||||
| pin_code_2 | number | ❌ | "PIN Code" (number) |
|
||||
| owner_name_2 | text | ❌ | "Owner Name" (text) |
|
||||
| phone_number_2 | phone | ❌ | "Phone Number" (phone) |
|
||||
| email_2 | email | ❌ | "Email" (email) |
|
||||
| store_location_2 | geolocation | ❌ | "Store Location" (geolocation) |
|
||||
| store_image_2 | image | ❌ | "Store Image" (image) |
|
||||
| route_name_2 | select | ❌ | "Route Name" (select) |
|
||||
| route_code_2 | select | ❌ | "Route Code" (select) |
|
||||
| sub_route_2 | select | ❌ | "Sub Route" (select) |
|
||||
| distributor_name_2 | select | ❌ | "Distributor Name" (select) |
|
||||
| distributor_owner_name_2 | text | ❌ | "Distributor Owner Name" (text) |
|
||||
| distributor_email_2 | email | ❌ | "Distributor Email" (email) |
|
||||
| distributor_phone_number_2 | phone | ❌ | "Distributor Phone Number" (phone) |
|
||||
| notes_2 | longtext | ❌ | "Notes" (longtext) |
|
||||
| potential_2 | grid | ❌ | "Potential" (grid) |
|
||||
|
||||
### 1.2 Edit Store
|
||||
|
||||
Advance an existing instance through this activity (normal submission).
|
||||
|
||||
- **Method:** `POST`
|
||||
- **Path:** `/app/434/activity` (base: core)
|
||||
- **Content-Type:** `application/json`
|
||||
|
||||
**Request body**
|
||||
|
||||
```json
|
||||
{
|
||||
"activity_id": "87647fae-3b07-445f-9615-21881b163276",
|
||||
"data": {
|
||||
"area_3": "string_value",
|
||||
"business_name_3": "string_value",
|
||||
"complete_address_3": "string_value",
|
||||
"distributor_email_3": "user@example.com",
|
||||
"distributor_name_3": "option_value",
|
||||
"distributor_owner_name_3": "string_value",
|
||||
"distributor_phone_number_3": {
|
||||
"dial_code": "+1",
|
||||
"phone": "2345678901",
|
||||
"phone_with_dial_code": "+12345678901"
|
||||
},
|
||||
"email_3": "user@example.com",
|
||||
"notes_3": "string_value",
|
||||
"owner_name_3": "string_value",
|
||||
"phone_number_3": {
|
||||
"dial_code": "+1",
|
||||
"phone": "2345678901",
|
||||
"phone_with_dial_code": "+12345678901"
|
||||
},
|
||||
"pin_code_3": 123,
|
||||
"potential_3": [
|
||||
{ "col_1": "option_value", "col_2": 123 }
|
||||
],
|
||||
"route_code_3": "option_value",
|
||||
"route_name_3": "option_value",
|
||||
"store_image_3": "<IMAGE_UPLOAD>",
|
||||
"store_location_3": "string_value",
|
||||
"sub_route_3": "option_value"
|
||||
},
|
||||
"instance_id": "<INSTANCE_ID>",
|
||||
"workflow_uuid": "2f827d27-b7a4-4ed9-9336-879fa46dbcba"
|
||||
}
|
||||
```
|
||||
|
||||
**Request fields**
|
||||
|
||||
| Key | Type | Required | Note |
|
||||
| -------------------------- | ----------- | -------- | -------------------------------- |
|
||||
| workflow_uuid | string | ✅ | Clone-portable workflow UID. |
|
||||
| activity_id | string | ✅ | This activity's UID. |
|
||||
| instance_id | int64 | ✅ | Target instance (runtime value). |
|
||||
| data | object | ❌ | Field values keyed by field id. |
|
||||
| business_name_3 | text | ❌ | "Business Name" (text) |
|
||||
| area_3 | text | ❌ | "Area" (text) |
|
||||
| complete_address_3 | longtext | ❌ | "Complete Address" (longtext) |
|
||||
| pin_code_3 | number | ❌ | "PIN Code" (number) |
|
||||
| owner_name_3 | text | ❌ | "Owner Name" (text) |
|
||||
| phone_number_3 | phone | ❌ | "Phone Number" (phone) |
|
||||
| email_3 | email | ❌ | "Email" (email) |
|
||||
| store_location_3 | geolocation | ❌ | "Store Location" (geolocation) |
|
||||
| store_image_3 | image | ❌ | "Store Image" (image) |
|
||||
| route_name_3 | select | ❌ | "Route Name" (select) |
|
||||
| route_code_3 | select | ❌ | "Route Code" (select) |
|
||||
| sub_route_3 | select | ❌ | "Sub Route" (select) |
|
||||
| distributor_name_3 | select | ❌ | "Distributor Name" (select) |
|
||||
| distributor_owner_name_3 | text | ❌ | "Distributor Owner Name" (text) |
|
||||
| distributor_email_3 | email | ❌ | "Distributor Email" (email) |
|
||||
| distributor_phone_number_3 | phone | ❌ | "Distributor Phone Number" (phone) |
|
||||
| notes_3 | longtext | ❌ | "Notes" (longtext) |
|
||||
| potential_3 | grid | ❌ | "Potential" (grid) |
|
||||
|
||||
### 1.3 Upload file
|
||||
|
||||
Upload a file/image during form fill; returns `blob_path` that replaces the File in the submit payload.
|
||||
|
||||
- **Method:** `POST`
|
||||
- **Path:** `/app/434/upload` (base: core)
|
||||
- **Content-Type:** `multipart/form-data` (browser sets the boundary automatically)
|
||||
|
||||
**Form fields**
|
||||
|
||||
| Key | Type | Required | Note |
|
||||
| ------------- | ------ | -------- | ------------------------------------------- |
|
||||
| file | binary | ✅ | The file contents. |
|
||||
| workflow_uuid | string | ✅ | Fixed: `2f827d27-b7a4-4ed9-9336-879fa46dbcba`. |
|
||||
| activity_id | string | ✅ | |
|
||||
| field_id | string | ✅ | |
|
||||
| column_id | string | ❌ | Only for grid-cell file columns. |
|
||||
| version_uuid | string | ❌ | |
|
||||
| instance_id | int64 | ❌ | Runtime value when editing an instance. |
|
||||
|
||||
**Response body**
|
||||
|
||||
```json
|
||||
{
|
||||
"blob_path": "12/a1b2c3.png",
|
||||
"mime_type": "image/png",
|
||||
"original_name": "photo.png",
|
||||
"size_bytes": 20480,
|
||||
"url": "<STORAGE_URL>"
|
||||
}
|
||||
```
|
||||
|
||||
| Key | Type | Note |
|
||||
| ------------- | ------ | --------------------------------------------- |
|
||||
| blob_path | string | Storage path that replaces the File in `data[field_id]`. |
|
||||
| original_name | string | |
|
||||
| mime_type | string | |
|
||||
| size_bytes | int64 | |
|
||||
| url | string | |
|
||||
|
||||
### Common submission response
|
||||
|
||||
Applies to Init Activity and Edit Store.
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {},
|
||||
"instance_id": 1024,
|
||||
"message": "Activity completed successfully",
|
||||
"status_code": 200,
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
| Key | Type | Note |
|
||||
| ----------- | ------ | --------------------------------------------------------------- |
|
||||
| success | bool | Whether the activity ran. |
|
||||
| status_code | int | HTTP status; mirrors a Response node if the trigger graph has one. |
|
||||
| message | string | Human-readable result; from a Response node when present. |
|
||||
| data | object | Response-node payload; empty object by default. |
|
||||
| instance_id | int64 | The workflow instance affected/created. |
|
||||
|
||||
---
|
||||
|
||||
## 2. View APIs
|
||||
|
||||
Read endpoints that power record/detail/activity/chart views, segregated by source type with each view's UI name.
|
||||
|
||||
### 2.1 Record Views
|
||||
|
||||
Paginated records + tiles + charts.
|
||||
|
||||
- **Method:** `POST`
|
||||
- **Path:** `/app/434/view/recordview` (base: view)
|
||||
- **Content-Type:** `application/json`
|
||||
|
||||
| UI Name | rv_template_uid |
|
||||
| ------- | -------------------------------------- |
|
||||
| Store | `c03b411a-8454-4d8b-8220-00095e92a5de` |
|
||||
|
||||
**Request body**
|
||||
|
||||
```json
|
||||
{
|
||||
"params": {},
|
||||
"preset_alias": "",
|
||||
"rv_template_uid": "c03b411a-8454-4d8b-8220-00095e92a5de",
|
||||
"search_query": {
|
||||
"filters": [],
|
||||
"limit": 25,
|
||||
"page": 1,
|
||||
"search": "",
|
||||
"sort_by": "",
|
||||
"sort_dir": "desc"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Request fields**
|
||||
|
||||
| Key | Type | Required | Note |
|
||||
| --------------------- | ------ | -------- | ----------------------------------------------------------- |
|
||||
| rv_template_uid | string | ✅ | This view's template UID. |
|
||||
| preset_alias | string | ❌ | Optional; selects a named prefilter preset on this view. |
|
||||
| params | object | ❌ | Values for declared input params (`${input.<key>}` refs). |
|
||||
| search_query.page | int | ❌ | 1-based page. |
|
||||
| search_query.limit | int | ❌ | Default 25, max 200. |
|
||||
| search_query.sort_by | string | ❌ | `field_key` \| `created_at` \| `updated_at` \| `instance_id`. |
|
||||
| search_query.sort_dir | string | ❌ | `asc` \| `desc`. |
|
||||
| search_query.search | string | ❌ | ILIKE term across searchable fields. |
|
||||
| search_query.filters | array | ❌ | `[{field_key, value, value2?, data_type}]`. |
|
||||
|
||||
**Response body**
|
||||
|
||||
```json
|
||||
{
|
||||
"chart_data": [],
|
||||
"data": [],
|
||||
"pagination": { "limit": 25, "page": 1, "total_records": 0 },
|
||||
"tile_values": {}
|
||||
}
|
||||
```
|
||||
|
||||
| Key | Type | Note |
|
||||
| ----------- | ------ | ------------------------------- |
|
||||
| data | array | Companion-table rows. |
|
||||
| tile_values | object | |
|
||||
| chart_data | array | |
|
||||
| pagination | object | `{page, limit, total_records}`. |
|
||||
|
||||
### 2.2 Detail Views
|
||||
|
||||
Single-instance detail record.
|
||||
|
||||
- **Method:** `GET`
|
||||
- **Base:** view
|
||||
|
||||
| UI Name | Path |
|
||||
| ------- | ---------------------------------------------------------------------------- |
|
||||
| Store | `/app/434/view/detailview/af868269-d340-4ea9-9793-5fabe4f6ae27?instance_id=<INSTANCE_ID>` |
|
||||
|
||||
**Request fields**
|
||||
|
||||
| Key | Type | Required | Note |
|
||||
| ----------- | ----- | -------- | ----------------------------------------------------------------- |
|
||||
| instance_id | int64 | ✅ | Query param (runtime value); some DVs use declared extra_params. |
|
||||
|
||||
**Response body**
|
||||
|
||||
```json
|
||||
{ "config": {}, "data": {} }
|
||||
```
|
||||
|
||||
| Key | Type | Note |
|
||||
| ------ | ------ | ----------------- |
|
||||
| config | object | |
|
||||
| data | object | `field_key→value`. |
|
||||
|
||||
---
|
||||
|
||||
## 3. In-form Activity Helper APIs
|
||||
|
||||
Calls a form makes while being filled, segregated per activity: lookup, workflow-lookup, app-user, dataset options, field actions, and peer-instance prefetch.
|
||||
|
||||
### Activity: Init Activity (`8626a77f-9e70-448e-8ac9-2c51eae5015b`)
|
||||
|
||||
#### Dataset options — Route Name
|
||||
|
||||
Dataset-backed options: mount + dependency change.
|
||||
|
||||
- **Method:** `POST`
|
||||
- **Path:** `/app/434/dataset-options` (base: core)
|
||||
- **Content-Type:** `application/json`
|
||||
|
||||
**Request body**
|
||||
|
||||
```json
|
||||
{
|
||||
"activity_id": "8626a77f-9e70-448e-8ac9-2c51eae5015b",
|
||||
"field_id": "route_name_2",
|
||||
"form_data": {},
|
||||
"limit": 50,
|
||||
"offset": 0,
|
||||
"search": "",
|
||||
"workflow_uuid": "2f827d27-b7a4-4ed9-9336-879fa46dbcba"
|
||||
}
|
||||
```
|
||||
|
||||
**Request fields**
|
||||
|
||||
| Key | Type | Required | Note |
|
||||
| ------------- | ------ | -------- | ----------------------------------------------- |
|
||||
| workflow_uuid | string | ✅ | Fixed: `2f827d27-b7a4-4ed9-9336-879fa46dbcba`. |
|
||||
| activity_id | string | ✅ | |
|
||||
| field_id | string | ✅ | |
|
||||
| search | string | ❌ | Search term. |
|
||||
| limit | int | ❌ | Default 50, max 200. |
|
||||
| offset | int | ❌ | |
|
||||
| filters | array | ❌ | `[{column, operator, value}]`. |
|
||||
| form_data | object | ❌ | Current form values for filter_options. |
|
||||
| version_uuid | string | ❌ | Pins filter_options to the deployed version. |
|
||||
|
||||
**Response body**
|
||||
|
||||
```json
|
||||
{ "limit": 50, "offset": 0, "records": [], "total": 0 }
|
||||
```
|
||||
|
||||
| Key | Type | Note |
|
||||
| ------- | ----- | ------------------------------------------------- |
|
||||
| records | array | For dataset-options this is `options:[{value,label}]`. |
|
||||
| total | int | |
|
||||
| limit | int | |
|
||||
| offset | int | |
|
||||
4
src/lib/cn.ts
Normal file
@ -0,0 +1,4 @@
|
||||
/** Join truthy class names. Tiny helper — no dependency needed. */
|
||||
export function cn(...parts: Array<string | false | null | undefined>): string {
|
||||
return parts.filter(Boolean).join(' ');
|
||||
}
|
||||
55
src/lib/format.ts
Normal file
@ -0,0 +1,55 @@
|
||||
// Turn a raw record/detail-view field value into a display string. The Zino
|
||||
// view APIs return heterogeneous shapes — scalars, phone objects, file blobs,
|
||||
// {label,value} option objects, and arrays (grids) — so normalize them here.
|
||||
|
||||
interface PhoneLike {
|
||||
phone_with_dial_code?: string;
|
||||
phone?: string;
|
||||
dial_code?: string;
|
||||
}
|
||||
|
||||
function isObject(v: unknown): v is Record<string, unknown> {
|
||||
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
||||
}
|
||||
|
||||
/** Best-effort single-line display string for a field value. */
|
||||
export function formatValue(value: unknown): string {
|
||||
if (value == null || value === '') return '—';
|
||||
if (typeof value === 'boolean') return value ? 'Yes' : 'No';
|
||||
if (typeof value === 'number' || typeof value === 'string') return String(value);
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) return '—';
|
||||
return `${value.length} item${value.length === 1 ? '' : 's'}`;
|
||||
}
|
||||
|
||||
if (isObject(value)) {
|
||||
const o = value as PhoneLike & Record<string, unknown>;
|
||||
// Phone
|
||||
if (o.phone_with_dial_code) return String(o.phone_with_dial_code);
|
||||
if (o.phone) return `${o.dial_code ?? ''}${o.phone}`.trim();
|
||||
// Option / lookup {label,value}
|
||||
if ('label' in o && o.label != null) return String(o.label);
|
||||
// File / image blob
|
||||
if ('original_name' in o && o.original_name != null) return String(o.original_name);
|
||||
if ('url' in o && o.url != null) return String(o.url);
|
||||
if ('name' in o && o.name != null) return String(o.name);
|
||||
if ('value' in o && o.value != null) return String(o.value);
|
||||
// Custom workflow lookups
|
||||
if ('business_name_2' in o && o.business_name_2 != null) return String(o.business_name_2);
|
||||
if ('business_name_3' in o && o.business_name_3 != null) return String(o.business_name_3);
|
||||
if ('business_name' in o && o.business_name != null) return String(o.business_name);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
13
src/main.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { registerSW } from 'virtual:pwa-register'
|
||||
import './styles/styles.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
registerSW({ immediate: true })
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
93
src/screens/CallsPage.tsx
Normal file
@ -0,0 +1,93 @@
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { Modal } from '../components/reusable';
|
||||
import { CallsView } from '../components/rv';
|
||||
import { CallDetail } from '../components/dv';
|
||||
import { useState } from 'react';
|
||||
import { Button } from '../components/buttons/Button';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { DynamicForm } from '../components/forms/DynamicForm';
|
||||
import { ORDER_BOOKING } from '../api/config';
|
||||
import { orderBookingClient } from '../api/clients';
|
||||
|
||||
export function CallsPage() {
|
||||
const params = useParams();
|
||||
const instanceId = params.instanceId ? Number(params.instanceId) : undefined;
|
||||
const navigate = useNavigate();
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const [activeActivity, setActiveActivity] = useState<{ id: string; name: string } | null>(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<CallsView
|
||||
refreshKey={refreshKey}
|
||||
onRowClick={(row) => {
|
||||
const id = row.instance_id as number | string | undefined;
|
||||
if (id != null) navigate(`/calls/${id}`);
|
||||
}}
|
||||
headerActions={
|
||||
<Button size="sm" iconLeft={<Plus size={14} />} onClick={() => setIsCreating(true)}>
|
||||
Log Visit
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
open={isCreating}
|
||||
onClose={() => setIsCreating(false)}
|
||||
title="Log Visit"
|
||||
width="md"
|
||||
>
|
||||
<DynamicForm
|
||||
client={orderBookingClient}
|
||||
activityId={ORDER_BOOKING.activities.LOG_VISIT.uid}
|
||||
onSuccess={() => {
|
||||
setIsCreating(false);
|
||||
setRefreshKey(k => k + 1);
|
||||
}}
|
||||
onCancel={() => setIsCreating(false)}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={instanceId != null}
|
||||
onClose={() => navigate(`/calls`)}
|
||||
title={instanceId != null ? `Call #${instanceId}` : undefined}
|
||||
width="lg"
|
||||
actions={
|
||||
<>
|
||||
<Button size="sm" variant="secondary" onClick={() => setActiveActivity({ id: ORDER_BOOKING.activities.POTENTIAL_MINING.uid, name: 'Potential Mining' })}>
|
||||
Potential Mining
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setActiveActivity({ id: ORDER_BOOKING.activities.PLACE_ORDER.uid, name: 'Place Order' })}>
|
||||
Place Order
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{instanceId != null && <CallDetail instanceId={instanceId} />}
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={activeActivity != null}
|
||||
onClose={() => setActiveActivity(null)}
|
||||
title={activeActivity?.name}
|
||||
width="md"
|
||||
>
|
||||
{activeActivity && instanceId != null && (
|
||||
<DynamicForm
|
||||
client={orderBookingClient}
|
||||
activityId={activeActivity.id}
|
||||
instanceId={instanceId}
|
||||
ignorePrefill={activeActivity.id === ORDER_BOOKING.activities.PLACE_ORDER.uid}
|
||||
onSuccess={() => {
|
||||
setActiveActivity(null);
|
||||
setRefreshKey(k => k + 1);
|
||||
}}
|
||||
onCancel={() => setActiveActivity(null)}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
77
src/screens/ConsoleLayout.tsx
Normal file
@ -0,0 +1,77 @@
|
||||
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 { SCREENS } from './tabs';
|
||||
|
||||
/** Auth-guarded shell: slim navy top bar + fixed bottom tab nav + routed <Outlet>. */
|
||||
export function ConsoleLayout() {
|
||||
const { authed, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const user = orderBookingClient.currentUser();
|
||||
|
||||
// A 401 from any workflow client bounces back to login.
|
||||
useEffect(() => {
|
||||
onAuthErrorAll(() => {
|
||||
logout();
|
||||
navigate('/login', { replace: true });
|
||||
});
|
||||
}, [logout, navigate]);
|
||||
|
||||
if (!authed) return <Navigate to="/login" replace />;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-app flex flex-col">
|
||||
<header className="sticky top-0 z-20 shrink-0 flex items-center justify-between gap-3 px-4 h-14 pt-[env(safe-area-inset-top)] bg-navy-grad border-b border-border-navy">
|
||||
<div className="flex flex-col justify-center leading-none">
|
||||
<span className="text-base font-extrabold text-on-navy tracking-[-0.01em] leading-none">Krishna Sales</span>
|
||||
<span className="text-2xs text-on-navy-muted">Sandbox {APP_ID}{user?.name ? ` · ${user.name}` : ''}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Sign out"
|
||||
onClick={() => {
|
||||
logout();
|
||||
navigate('/login', { replace: true });
|
||||
}}
|
||||
className="inline-flex items-center justify-center size-9 rounded-md text-on-navy-muted hover:text-white hover:bg-white/10 transition-colors duration-150 cursor-pointer"
|
||||
>
|
||||
<LogOut size={18} />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 overflow-y-auto px-4 pt-4 pb-24">
|
||||
<div className="max-w-[720px] w-full mx-auto flex flex-col gap-5">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<nav
|
||||
className="fixed bottom-0 inset-x-0 z-20 h-16 pb-[env(safe-area-inset-bottom)] bg-card border-t border-border-subtle grid"
|
||||
style={{ gridTemplateColumns: `repeat(${SCREENS.length}, minmax(0, 1fr))` }}
|
||||
>
|
||||
{SCREENS.map((t) => {
|
||||
const Icon = t.icon;
|
||||
return (
|
||||
<NavLink
|
||||
key={t.key}
|
||||
to={`/${t.key}`}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
'flex flex-col items-center justify-center gap-1 no-underline min-h-[48px]',
|
||||
isActive ? 'text-sunrise-500' : 'text-faint',
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon size={22} />
|
||||
<span className="text-2xs font-medium leading-none">{t.label}</span>
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
87
src/screens/DailyLogsPage.tsx
Normal file
@ -0,0 +1,87 @@
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { Modal } from '../components/reusable';
|
||||
import { DailyLogsView } from '../components/rv';
|
||||
import { DailyLogDetail } from '../components/dv';
|
||||
import { useState } from 'react';
|
||||
import { Button } from '../components/buttons/Button';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { DynamicForm } from '../components/forms/DynamicForm';
|
||||
import { DAILY_REPORTS } from '../api/config';
|
||||
import { dailyReportsClient } from '../api/clients';
|
||||
|
||||
export function DailyLogsPage() {
|
||||
const params = useParams();
|
||||
const instanceId = params.instanceId ? Number(params.instanceId) : undefined;
|
||||
const navigate = useNavigate();
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [punchOutInstanceId, setPunchOutInstanceId] = useState<number | string | null>(null);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DailyLogsView
|
||||
refreshKey={refreshKey}
|
||||
onRowClick={(row) => {
|
||||
const id = row.instance_id as number | string | undefined;
|
||||
if (id != null) navigate(`/daily/${id}`);
|
||||
}}
|
||||
headerActions={
|
||||
<Button size="sm" iconLeft={<Plus size={14} />} onClick={() => setIsCreating(true)}>
|
||||
Punch In
|
||||
</Button>
|
||||
}
|
||||
rowActions={(row) => (
|
||||
<Button size="sm" variant="secondary" onClick={() => setPunchOutInstanceId(row.instance_id as string | number)}>
|
||||
Punch Out
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
open={isCreating}
|
||||
onClose={() => setIsCreating(false)}
|
||||
title="Punch In"
|
||||
width="md"
|
||||
>
|
||||
<DynamicForm
|
||||
client={dailyReportsClient}
|
||||
activityId={DAILY_REPORTS.activities.INIT.uid}
|
||||
onSuccess={() => {
|
||||
setIsCreating(false);
|
||||
setRefreshKey(k => k + 1);
|
||||
}}
|
||||
onCancel={() => setIsCreating(false)}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={punchOutInstanceId != null}
|
||||
onClose={() => setPunchOutInstanceId(null)}
|
||||
title="Punch Out"
|
||||
width="md"
|
||||
>
|
||||
{punchOutInstanceId != null && (
|
||||
<DynamicForm
|
||||
client={dailyReportsClient}
|
||||
activityId={DAILY_REPORTS.activities.PUNCH_OUT.uid}
|
||||
instanceId={punchOutInstanceId}
|
||||
onSuccess={() => {
|
||||
setPunchOutInstanceId(null);
|
||||
setRefreshKey(k => k + 1);
|
||||
}}
|
||||
onCancel={() => setPunchOutInstanceId(null)}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={instanceId != null}
|
||||
onClose={() => navigate(`/daily`)}
|
||||
title={instanceId != null ? `Daily Log #${instanceId}` : undefined}
|
||||
width="lg"
|
||||
>
|
||||
{instanceId != null && <DailyLogDetail instanceId={instanceId} />}
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
53
src/screens/LoginPage.tsx
Normal file
@ -0,0 +1,53 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { Navigate, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../auth/context';
|
||||
import { APP_ID } from '../api/config';
|
||||
import { Button } from '../components/buttons';
|
||||
import { Card, Input } from '../components/reusable';
|
||||
|
||||
/** Login gate. Redirects to /orders once authenticated. */
|
||||
export function LoginPage() {
|
||||
const { authed, login } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [orgId, setOrgId] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
if (authed) return <Navigate to="/orders" replace />;
|
||||
|
||||
async function submit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await login(email, password, orgId || undefined);
|
||||
navigate('/orders', { replace: true });
|
||||
} catch (err) {
|
||||
setError((err as { message?: string })?.message ?? 'Login failed');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4 bg-app">
|
||||
<Card className="w-full max-w-[400px]">
|
||||
<div className="flex flex-col gap-1 mb-5">
|
||||
<h1 className="m-0 text-2xl font-extrabold text-strong tracking-[-0.02em]">Krishna Sales</h1>
|
||||
<p className="m-0 text-sm text-faint">Field Sales console · Sandbox {APP_ID}</p>
|
||||
</div>
|
||||
<form onSubmit={submit} className="flex flex-col gap-4">
|
||||
<Input label="Email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} required autoFocus />
|
||||
<Input label="Password" type="password" value={password} onChange={(e) => setPassword(e.target.value)} required />
|
||||
<Input label="Org ID" hint="Optional" value={orgId} onChange={(e) => setOrgId(e.target.value)} />
|
||||
{error && <div className="text-xs text-ruby-600 font-medium">{error}</div>}
|
||||
<Button type="submit" full disabled={busy}>
|
||||
{busy ? 'Signing in…' : 'Sign in'}
|
||||
</Button>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
93
src/screens/OrdersPage.tsx
Normal file
@ -0,0 +1,93 @@
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { Modal } from '../components/reusable';
|
||||
import { OrdersView } from '../components/rv';
|
||||
import { OrderDetail } from '../components/dv';
|
||||
import { useState } from 'react';
|
||||
import { Button } from '../components/buttons/Button';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { DynamicForm } from '../components/forms/DynamicForm';
|
||||
import { ORDER_BOOKING } from '../api/config';
|
||||
import { orderBookingClient } from '../api/clients';
|
||||
|
||||
export function OrdersPage() {
|
||||
const params = useParams();
|
||||
const instanceId = params.instanceId ? Number(params.instanceId) : undefined;
|
||||
const navigate = useNavigate();
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
const [activeActivity, setActiveActivity] = useState<{ id: string; name: string } | null>(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<OrdersView
|
||||
refreshKey={refreshKey}
|
||||
onRowClick={(row) => {
|
||||
const id = row.instance_id as number | string | undefined;
|
||||
if (id != null) navigate(`/orders/${id}`);
|
||||
}}
|
||||
headerActions={
|
||||
<Button size="sm" iconLeft={<Plus size={14} />} onClick={() => setIsCreating(true)}>
|
||||
Place Order
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
open={isCreating}
|
||||
onClose={() => setIsCreating(false)}
|
||||
title="Place Order"
|
||||
width="md"
|
||||
>
|
||||
<DynamicForm
|
||||
client={orderBookingClient}
|
||||
activityId={ORDER_BOOKING.activities.LOG_VISIT.uid}
|
||||
onSuccess={() => {
|
||||
setIsCreating(false);
|
||||
setRefreshKey(k => k + 1);
|
||||
}}
|
||||
onCancel={() => setIsCreating(false)}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={instanceId != null}
|
||||
onClose={() => navigate(`/orders`)}
|
||||
title={instanceId != null ? `Order #${instanceId}` : undefined}
|
||||
width="lg"
|
||||
actions={
|
||||
<>
|
||||
<Button size="sm" variant="secondary" onClick={() => setActiveActivity({ id: ORDER_BOOKING.activities.POTENTIAL_MINING.uid, name: 'Potential Mining' })}>
|
||||
Potential Mining
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => setActiveActivity({ id: ORDER_BOOKING.activities.PLACE_ORDER.uid, name: 'Place Order' })}>
|
||||
Place Order
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{instanceId != null && <OrderDetail instanceId={instanceId} />}
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={activeActivity != null}
|
||||
onClose={() => setActiveActivity(null)}
|
||||
title={activeActivity?.name}
|
||||
width="md"
|
||||
>
|
||||
{activeActivity && instanceId != null && (
|
||||
<DynamicForm
|
||||
client={orderBookingClient}
|
||||
activityId={activeActivity.id}
|
||||
instanceId={instanceId}
|
||||
ignorePrefill={activeActivity.id === ORDER_BOOKING.activities.PLACE_ORDER.uid}
|
||||
onSuccess={() => {
|
||||
setActiveActivity(null);
|
||||
setRefreshKey(k => k + 1);
|
||||
}}
|
||||
onCancel={() => setActiveActivity(null)}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
87
src/screens/StoresPage.tsx
Normal file
@ -0,0 +1,87 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { Modal } from '../components/reusable';
|
||||
import { StoresView } from '../components/rv';
|
||||
import { StoreDetail } from '../components/dv';
|
||||
import { Button } from '../components/buttons/Button';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { DynamicForm } from '../components/forms/DynamicForm';
|
||||
import { STORE } from '../api/config';
|
||||
import { storeClient } from '../api/clients';
|
||||
|
||||
export function StoresPage() {
|
||||
const params = useParams();
|
||||
const instanceId = params.instanceId ? Number(params.instanceId) : undefined;
|
||||
const navigate = useNavigate();
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [editingInstanceId, setEditingInstanceId] = useState<number | string | null>(null);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
|
||||
return (
|
||||
<>
|
||||
<StoresView
|
||||
refreshKey={refreshKey}
|
||||
onRowClick={(row) => {
|
||||
const id = row.instance_id as number | string | undefined;
|
||||
if (id != null) navigate(`/stores/${id}`);
|
||||
}}
|
||||
headerActions={
|
||||
<Button size="sm" iconLeft={<Plus size={14} />} onClick={() => setIsCreating(true)}>
|
||||
Create Store
|
||||
</Button>
|
||||
}
|
||||
rowActions={(row) => (
|
||||
<Button size="sm" variant="secondary" onClick={() => setEditingInstanceId(row.instance_id as string | number)}>
|
||||
Edit
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
open={isCreating}
|
||||
onClose={() => setIsCreating(false)}
|
||||
title="Create Store"
|
||||
width="md"
|
||||
>
|
||||
<DynamicForm
|
||||
client={storeClient}
|
||||
activityId={STORE.activities.INIT.uid}
|
||||
onSuccess={() => {
|
||||
setIsCreating(false);
|
||||
setRefreshKey(k => k + 1);
|
||||
}}
|
||||
onCancel={() => setIsCreating(false)}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={editingInstanceId != null}
|
||||
onClose={() => setEditingInstanceId(null)}
|
||||
title="Edit Store"
|
||||
width="md"
|
||||
>
|
||||
{editingInstanceId != null && (
|
||||
<DynamicForm
|
||||
client={storeClient}
|
||||
activityId={STORE.activities.EDIT_STORE.uid}
|
||||
instanceId={editingInstanceId}
|
||||
onSuccess={() => {
|
||||
setEditingInstanceId(null);
|
||||
setRefreshKey(k => k + 1);
|
||||
}}
|
||||
onCancel={() => setEditingInstanceId(null)}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={instanceId != null && !isCreating}
|
||||
onClose={() => navigate(`/stores`)}
|
||||
title={instanceId != null ? `Store #${instanceId}` : undefined}
|
||||
width="lg"
|
||||
>
|
||||
{instanceId != null && <StoreDetail instanceId={instanceId} />}
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
38
src/screens/tabs.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import { Store, Phone, ShoppingCart, ClipboardList, type LucideIcon } from 'lucide-react';
|
||||
import {
|
||||
OrdersView,
|
||||
CallsView,
|
||||
StoresView,
|
||||
DailyLogsView,
|
||||
type WiredRecordViewProps,
|
||||
} from '../components/rv';
|
||||
import {
|
||||
OrderDetail,
|
||||
CallDetail,
|
||||
StoreDetail,
|
||||
DailyLogDetail,
|
||||
type WiredDetailViewProps,
|
||||
} from '../components/dv';
|
||||
|
||||
export type ScreenKey = 'orders' | 'calls' | 'stores' | 'daily';
|
||||
|
||||
export interface ScreenDef {
|
||||
key: ScreenKey;
|
||||
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 SCREENS: ScreenDef[] = [
|
||||
{ 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 screenByKey(key: string | undefined): ScreenDef | undefined {
|
||||
return SCREENS.find((t) => t.key === key);
|
||||
}
|
||||
177
src/styles/styles.css
Normal file
@ -0,0 +1,177 @@
|
||||
/* ============================================================
|
||||
KRISHNA SALES — global stylesheet
|
||||
Tailwind v4 + design tokens. The @theme inline block wires
|
||||
Tailwind's utility namespaces to the CSS-variable tokens, so
|
||||
`bg-navy-900`, `rounded-lg`, `shadow-md`, `font-numeric` etc.
|
||||
resolve to the design system with zero duplication.
|
||||
(Referenced from the lead-to-policy console.)
|
||||
============================================================ */
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:opsz,wght@14..32,400;14..32,500;14..32,600;14..32,700;14..32,800&family=Inter+Tight:wght@500;600;700;800&display=swap');
|
||||
@import 'tailwindcss';
|
||||
@import './tokens/colors.css';
|
||||
@import './tokens/typography.css';
|
||||
@import './tokens/spacing.css';
|
||||
|
||||
@theme inline {
|
||||
/* ---- Brand: Navy ---- */
|
||||
--color-navy-950: var(--navy-950);
|
||||
--color-navy-900: var(--navy-900);
|
||||
--color-navy-800: var(--navy-800);
|
||||
--color-navy-700: var(--navy-700);
|
||||
--color-navy-600: var(--navy-600);
|
||||
--color-navy-500: var(--navy-500);
|
||||
--color-navy-400: var(--navy-400);
|
||||
|
||||
/* ---- Brand: Sunrise ---- */
|
||||
--color-sunrise-600: var(--sunrise-600);
|
||||
--color-sunrise-500: var(--sunrise-500);
|
||||
--color-sunrise-400: var(--sunrise-400);
|
||||
--color-sunrise-300: var(--sunrise-300);
|
||||
--color-sunrise-200: var(--sunrise-200);
|
||||
--color-sunrise-100: var(--sunrise-100);
|
||||
|
||||
/* ---- Semantic: Emerald / Amber / Ruby / Sky ---- */
|
||||
--color-emerald-700: var(--emerald-700);
|
||||
--color-emerald-600: var(--emerald-600);
|
||||
--color-emerald-500: var(--emerald-500);
|
||||
--color-emerald-300: var(--emerald-300);
|
||||
--color-emerald-100: var(--emerald-100);
|
||||
--color-amber-700: var(--amber-700);
|
||||
--color-amber-600: var(--amber-600);
|
||||
--color-amber-500: var(--amber-500);
|
||||
--color-amber-300: var(--amber-300);
|
||||
--color-amber-100: var(--amber-100);
|
||||
--color-ruby-700: var(--ruby-700);
|
||||
--color-ruby-600: var(--ruby-600);
|
||||
--color-ruby-500: var(--ruby-500);
|
||||
--color-ruby-100: var(--ruby-100);
|
||||
--color-sky-700: var(--sky-700);
|
||||
--color-sky-600: var(--sky-600);
|
||||
--color-sky-500: var(--sky-500);
|
||||
--color-sky-100: var(--sky-100);
|
||||
|
||||
/* ---- Neutral: Slate ---- */
|
||||
--color-slate-950: var(--slate-950);
|
||||
--color-slate-900: var(--slate-900);
|
||||
--color-slate-800: var(--slate-800);
|
||||
--color-slate-700: var(--slate-700);
|
||||
--color-slate-600: var(--slate-600);
|
||||
--color-slate-500: var(--slate-500);
|
||||
--color-slate-400: var(--slate-400);
|
||||
--color-slate-300: var(--slate-300);
|
||||
--color-slate-200: var(--slate-200);
|
||||
--color-slate-150: var(--slate-150);
|
||||
--color-slate-100: var(--slate-100);
|
||||
--color-slate-50: var(--slate-50);
|
||||
|
||||
/* ---- Semantic aliases (short, readable utility names) ---- */
|
||||
--color-app: var(--surface-app);
|
||||
--color-card: var(--surface-card);
|
||||
--color-sunk: var(--surface-sunk);
|
||||
--color-strong: var(--text-strong);
|
||||
--color-body: var(--text-body);
|
||||
--color-muted: var(--text-muted);
|
||||
--color-faint: var(--text-faint);
|
||||
--color-on-navy: var(--text-on-navy);
|
||||
--color-on-navy-muted: var(--text-on-navy-muted);
|
||||
--color-accent: var(--text-accent);
|
||||
--color-link: var(--text-link);
|
||||
--color-border-subtle: var(--border-subtle);
|
||||
--color-border-default: var(--border-default);
|
||||
|
||||
/* ---- Radii ---- */
|
||||
--radius-xs: var(--radius-xs);
|
||||
--radius-sm: var(--radius-sm);
|
||||
--radius-md: var(--radius-md);
|
||||
--radius-lg: var(--radius-lg);
|
||||
--radius-xl: var(--radius-xl);
|
||||
--radius-2xl: var(--radius-2xl);
|
||||
--radius-pill: var(--radius-pill);
|
||||
|
||||
/* ---- Elevation ---- */
|
||||
--shadow-xs: var(--shadow-xs);
|
||||
--shadow-sm: var(--shadow-sm);
|
||||
--shadow-md: var(--shadow-md);
|
||||
--shadow-lg: var(--shadow-lg);
|
||||
--shadow-xl: var(--shadow-xl);
|
||||
--shadow-sunrise: var(--shadow-sunrise);
|
||||
|
||||
/* ---- Fonts ---- */
|
||||
--font-sans: var(--font-sans);
|
||||
--font-numeric: var(--font-numeric);
|
||||
--font-mono: var(--font-mono);
|
||||
|
||||
/* ---- Type scale ---- */
|
||||
--text-2xs: var(--text-2xs);
|
||||
--text-xs: var(--text-xs);
|
||||
--text-sm: var(--text-sm);
|
||||
--text-base: var(--text-base);
|
||||
--text-md: var(--text-md);
|
||||
--text-lg: var(--text-lg);
|
||||
--text-xl: var(--text-xl);
|
||||
--text-2xl: var(--text-2xl);
|
||||
--text-3xl: var(--text-3xl);
|
||||
--text-4xl: var(--text-4xl);
|
||||
--text-5xl: var(--text-5xl);
|
||||
--text-6xl: var(--text-6xl);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
height: 100%;
|
||||
}
|
||||
body,
|
||||
#root {
|
||||
min-height: 100%;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--font-sans);
|
||||
background: var(--surface-app);
|
||||
color: var(--text-body);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* Brand gradients — not expressible as plain utilities. */
|
||||
.bg-sunrise {
|
||||
background-image: var(--gradient-sunrise);
|
||||
}
|
||||
.bg-sunrise-soft {
|
||||
background-image: var(--gradient-sunrise-soft);
|
||||
}
|
||||
.bg-navy-grad {
|
||||
background-image: var(--gradient-navy);
|
||||
}
|
||||
/* Tabular figures for aligning currency & counts. */
|
||||
.nums {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
/* Sunrise focus ring on inputs/selects. */
|
||||
.focus-ring:focus-within {
|
||||
border-color: var(--sunrise-500);
|
||||
box-shadow: var(--shadow-focus);
|
||||
}
|
||||
/* Slim, theme-tinted scrollbar for in-panel scroll areas. */
|
||||
.scrollbar-slim {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border-default) transparent;
|
||||
}
|
||||
.scrollbar-slim::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
.scrollbar-slim::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.scrollbar-slim::-webkit-scrollbar-thumb {
|
||||
background-color: var(--border-default);
|
||||
background-clip: padding-box;
|
||||
border: 3px solid transparent;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
.scrollbar-slim:hover::-webkit-scrollbar-thumb {
|
||||
background-color: var(--text-faint);
|
||||
}
|
||||
}
|
||||
111
src/styles/tokens/colors.css
Normal file
@ -0,0 +1,111 @@
|
||||
/* ============================================================
|
||||
KRISHNA SALES — COLOR TOKENS
|
||||
Field-sales console. Base deep navy, sunrise accent gradient,
|
||||
emerald success, amber warning, slate neutrals.
|
||||
(Design system referenced from the lead-to-policy console.)
|
||||
============================================================ */
|
||||
:root {
|
||||
/* ---- Brand: Navy (primary surface + ink) ---- */
|
||||
--navy-950: #060F24;
|
||||
--navy-900: #0B1B3B; /* base deep navy */
|
||||
--navy-800: #122549;
|
||||
--navy-700: #1B3260;
|
||||
--navy-600: #294780;
|
||||
--navy-500: #3A5DA0;
|
||||
--navy-400: #6987BF;
|
||||
|
||||
/* ---- Brand: Sunrise (accent gradient) ---- */
|
||||
--sunrise-600: #E2552A;
|
||||
--sunrise-500: #F26B3A; /* gradient start */
|
||||
--sunrise-400: #FB8A4A;
|
||||
--sunrise-300: #FBA94C; /* gradient end */
|
||||
--sunrise-200: #FDD09A;
|
||||
--sunrise-100: #FEF0E0;
|
||||
--gradient-sunrise: linear-gradient(100deg, #F26B3A 0%, #FBA94C 100%);
|
||||
--gradient-sunrise-soft: linear-gradient(135deg, #FEF0E0 0%, #FDE4D2 100%);
|
||||
--gradient-navy: linear-gradient(160deg, #0B1B3B 0%, #122549 60%, #1B3260 100%);
|
||||
|
||||
/* ---- Semantic: Emerald (success) ---- */
|
||||
--emerald-700: #0A7A52;
|
||||
--emerald-600: #0E9466;
|
||||
--emerald-500: #16B17C;
|
||||
--emerald-300: #6FD7B0;
|
||||
--emerald-100: #DBF5EB;
|
||||
|
||||
/* ---- Semantic: Amber (warning) ---- */
|
||||
--amber-700: #B26B05;
|
||||
--amber-600: #D98512;
|
||||
--amber-500: #F0A529;
|
||||
--amber-300: #FBCF7E;
|
||||
--amber-100: #FCF1DB;
|
||||
|
||||
/* ---- Semantic: Ruby (error) ---- */
|
||||
--ruby-700: #B4243B;
|
||||
--ruby-600: #D63A52;
|
||||
--ruby-500: #E85A70;
|
||||
--ruby-100: #FBE2E6;
|
||||
|
||||
/* ---- Semantic: Sky (info) ---- */
|
||||
--sky-700: #1565B8;
|
||||
--sky-600: #2480DB;
|
||||
--sky-500: #4AA0EF;
|
||||
--sky-100: #DCEEFC;
|
||||
|
||||
/* ---- Neutral: Slate ---- */
|
||||
--slate-950: #0E1726;
|
||||
--slate-900: #1B2638;
|
||||
--slate-800: #2E3B52;
|
||||
--slate-700: #45536E;
|
||||
--slate-600: #5E6E8C;
|
||||
--slate-500: #7C8AA6;
|
||||
--slate-400: #9DA9C2;
|
||||
--slate-300: #C2CADB;
|
||||
--slate-200: #DDE3EE;
|
||||
--slate-150: #E8ECF4;
|
||||
--slate-100: #F0F3F8;
|
||||
--slate-50: #F7F9FC;
|
||||
--white: #FFFFFF;
|
||||
|
||||
/* ---- Semantic aliases (light mode) ---- */
|
||||
/* Surfaces */
|
||||
--surface-app: var(--slate-50);
|
||||
--surface-card: var(--white);
|
||||
--surface-sunk: var(--slate-100);
|
||||
--surface-raised: var(--white);
|
||||
--surface-navy: var(--navy-900);
|
||||
--surface-navy-soft: var(--navy-800);
|
||||
|
||||
/* Text */
|
||||
--text-strong: var(--navy-900);
|
||||
--text-body: var(--slate-800);
|
||||
--text-muted: var(--slate-600);
|
||||
--text-faint: var(--slate-500);
|
||||
--text-on-navy: #EAF0FA;
|
||||
--text-on-navy-muted: #9DB0D2;
|
||||
--text-on-accent: #FFFFFF;
|
||||
--text-accent: var(--sunrise-600);
|
||||
--text-link: var(--sky-700);
|
||||
|
||||
/* Borders / hairlines */
|
||||
--border-subtle: var(--slate-200);
|
||||
--border-default: var(--slate-300);
|
||||
--border-strong: var(--slate-400);
|
||||
--border-navy: rgba(255,255,255,0.12);
|
||||
|
||||
/* Brand action */
|
||||
--action-primary: var(--sunrise-500);
|
||||
--action-primary-hover: var(--sunrise-600);
|
||||
--action-navy: var(--navy-900);
|
||||
--action-navy-hover: var(--navy-800);
|
||||
--focus-ring: rgba(242,107,58,0.45);
|
||||
|
||||
/* Status */
|
||||
--status-success: var(--emerald-600);
|
||||
--status-success-soft: var(--emerald-100);
|
||||
--status-warning: var(--amber-600);
|
||||
--status-warning-soft: var(--amber-100);
|
||||
--status-error: var(--ruby-600);
|
||||
--status-error-soft: var(--ruby-100);
|
||||
--status-info: var(--sky-600);
|
||||
--status-info-soft: var(--sky-100);
|
||||
}
|
||||
25
src/styles/tokens/spacing.css
Normal file
@ -0,0 +1,25 @@
|
||||
/* ============================================================
|
||||
KRISHNA SALES — RADII & ELEVATION
|
||||
============================================================ */
|
||||
:root {
|
||||
/* ---- Corner radii ---- */
|
||||
--radius-xs: 6px;
|
||||
--radius-sm: 8px;
|
||||
--radius-md: 12px; /* default control radius */
|
||||
--radius-lg: 16px; /* default card radius */
|
||||
--radius-xl: 20px;
|
||||
--radius-2xl: 28px;
|
||||
--radius-pill: 999px;
|
||||
|
||||
/* ---- Soft elevation (navy-tinted, low spread) ---- */
|
||||
--shadow-xs: 0 1px 2px rgba(11,27,59,0.06);
|
||||
--shadow-sm: 0 1px 3px rgba(11,27,59,0.08), 0 1px 2px rgba(11,27,59,0.04);
|
||||
--shadow-md: 0 4px 12px rgba(11,27,59,0.08), 0 2px 4px rgba(11,27,59,0.04);
|
||||
--shadow-lg: 0 12px 28px rgba(11,27,59,0.12), 0 4px 8px rgba(11,27,59,0.06);
|
||||
--shadow-xl: 0 24px 56px rgba(11,27,59,0.18), 0 8px 16px rgba(11,27,59,0.08);
|
||||
--shadow-focus: 0 0 0 3px var(--focus-ring);
|
||||
--shadow-sunrise: 0 8px 24px rgba(242,107,58,0.22);
|
||||
|
||||
/* ---- Layout ---- */
|
||||
--container-max: 1280px;
|
||||
}
|
||||
30
src/styles/tokens/typography.css
Normal file
@ -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;
|
||||
}
|
||||
2
src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
/// <reference types="vite/client" />
|
||||
/// <reference types="vite-plugin-pwa/client" />
|
||||
26
tsconfig.app.json
Normal file
@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023", "DOM"],
|
||||
"module": "esnext",
|
||||
"types": ["vite/client"],
|
||||
"allowArbitraryExtensions": true,
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
23
tsconfig.node.json
Normal file
@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "es2023",
|
||||
"lib": ["ES2023"],
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"module": "nodenext",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
33
vite.config.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
tailwindcss(),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
pwaAssets: { image: 'public/logo.svg' },
|
||||
manifest: {
|
||||
name: 'Krishna Sales',
|
||||
short_name: 'Krishna',
|
||||
description: 'Field Sales console',
|
||||
theme_color: '#0B1B3B',
|
||||
background_color: '#0B1B3B',
|
||||
display: 'standalone',
|
||||
orientation: 'portrait',
|
||||
start_url: '/orders',
|
||||
scope: '/',
|
||||
},
|
||||
workbox: {
|
||||
navigateFallback: '/index.html',
|
||||
navigateFallbackDenylist: [/^\/usr\//, /^\/app\//],
|
||||
globPatterns: ['**/*.{js,css,html,svg,png,ico,woff2}'],
|
||||
},
|
||||
devOptions: { enabled: false },
|
||||
}),
|
||||
],
|
||||
})
|
||||