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.
This commit is contained in:
Bhanu Prakash Sai Potteri 2026-07-08 11:27:35 +05:30
commit 73850d6e0a
86 changed files with 14368 additions and 0 deletions

27
.gitignore vendored Normal file
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

38
package.json Normal file
View 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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 684 B

10
public/favicon.svg Normal file
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

BIN
public/pwa-192x192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

BIN
public/pwa-512x512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

BIN
public/pwa-64x64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 560 B

6
pwa-assets.config.ts Normal file
View 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
View 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
View 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
View File

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

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

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

223
src/api/types.ts Normal file
View 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
View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,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="Couldnt 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>
);
}

View File

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

View File

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

View File

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

@ -0,0 +1,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>
);
}

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,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,
);
}

View File

@ -0,0 +1,41 @@
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { Button } from '../buttons/Button';
export interface PaginationProps {
/** 1-based current page. */
page: number;
pageSize: number;
/** Total rows across all pages. */
total: number;
onPage: (page: number) => void;
}
/** Pager: "Showing ab of N" + Prev/Next. Renders nothing when the set fits on
* one page. Shared by the record-view tables. */
export function Pagination({ page, pageSize, total, onPage }: PaginationProps) {
if (total <= 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>
);
}

View File

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

View File

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

View File

@ -0,0 +1,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>
);
}

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

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

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

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

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

View 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="Couldnt load records" hint={error} />
) : loading && !resp ? (
<div className="p-10 flex justify-center">
<Spinner label="Loading records…" />
</div>
) : rows.length === 0 ? (
<EmptyState title="No records" hint={debounced ? 'Nothing matches your search.' : undefined} />
) : (
<div className="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>
);
}

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

View File

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

View File

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

View File

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

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

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

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

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

55
src/lib/format.ts Normal file
View 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
View 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
View 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>
</>
);
}

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

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

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

View File

@ -0,0 +1,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>
</>
);
}

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

@ -0,0 +1,38 @@
import { Store, Phone, ShoppingCart, ClipboardList, type LucideIcon } from 'lucide-react';
import {
OrdersView,
CallsView,
StoresView,
DailyLogsView,
type WiredRecordViewProps,
} from '../components/rv';
import {
OrderDetail,
CallDetail,
StoreDetail,
DailyLogDetail,
type WiredDetailViewProps,
} from '../components/dv';
export type 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
View File

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

View File

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

View File

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

View File

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

2
src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1,2 @@
/// <reference types="vite/client" />
/// <reference types="vite-plugin-pwa/client" />

26
tsconfig.app.json Normal file
View 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
View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

23
tsconfig.node.json Normal file
View 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
View 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 },
}),
],
})