diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..46cf10e --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +# Local dev only — a deployed placement gets its API URL from the config.js +# the frontgen server writes next to index.html (window.__RUNTIME_CONFIG__). +VITE_ZINO_API_URL=https://dev.getzino.in diff --git a/index.html b/index.html index f1ad924..6bd3477 100644 --- a/index.html +++ b/index.html @@ -3,11 +3,13 @@ - + P2P + +
diff --git a/src/api/viewService.ts b/src/api/viewService.ts index 098624c..91752be 100644 --- a/src/api/viewService.ts +++ b/src/api/viewService.ts @@ -1,9 +1,9 @@ -import { APP_ID } from "../config"; +import { APP_ID, BASE_URL } from "../config"; const TOKEN_KEY = "p2p_token"; function baseUrl(): string { - return (import.meta.env.VITE_ZINO_API_URL || "https://studio.getzino.in").replace(/\/+$/, ""); + return BASE_URL.replace(/\/+$/, ""); } function headers(): Record { @@ -62,6 +62,25 @@ async function postFile(path: string, file: File, ctx: FieldFileContext): Pro return parseResponse(res); } +// --------------------------------------------------------------------------- +// Current user (user-service) +// --------------------------------------------------------------------------- + +export interface MeResponse { + id: string; + org_id: string; + name: string; + email: string; + mobile?: string; + roles?: string[]; + groups?: string[]; +} + +/** Fresh profile for the logged-in user, app-scoped. */ +export function getMe(): Promise { + return request("GET", `/usr/app/${APP_ID}/me`); +} + // --------------------------------------------------------------------------- // Header // --------------------------------------------------------------------------- @@ -151,7 +170,11 @@ export interface SearchQuery { sort_by?: string; sort_dir?: "asc" | "desc"; search?: string; - filters?: Array<{ field_key: string; value: string; data_type?: string }>; + // `values` (multi-value, ORed server-side) supersedes repeating the same + // field_key with single `value` entries. + filters?: Array<{ field_key: string; value?: string; values?: string[]; data_type?: string }>; + // Named filter preset configured on the RV template. + preset_alias?: string; } export interface RecordViewField { @@ -203,13 +226,26 @@ export function getRecordView( return request("POST", `/app/${APP_ID}/view/recordview`, { rv_template_uid: rvTemplateUid, rv_screen_id: String(rvScreenId), + ...(query.preset_alias ? { preset_alias: query.preset_alias } : {}), search_query: { page: query.page || 1, limit: query.limit || 50, sort_by: query.sort_by || "", sort_dir: query.sort_dir || "desc", search: query.search || "", - filters: query.filters || [], + filters: (query.filters || []).map((f) => { + const item: Record = { + field_key: f.field_key, + data_type: f.data_type ?? "character varying", + }; + if (f.values && f.values.length > 0) { + item.values = f.values; + } else { + item.value = f.value ?? ""; + item.value2 = ""; + } + return item; + }), }, }); } @@ -641,21 +677,83 @@ export interface RdbmsLookupResponse { } export interface RdbmsLookupQuery { - workflowId: number; + workflowUuid: string; activityId: string; fieldId: string; + instanceId?: string; limit?: number; offset?: number; search?: string; + // Current form values, so the server can apply the activity's field_rules + // filter_options (dependent/cascading lookups). + formData?: Record; } +// Core-service expects the workflow UUID as `workflow_uuid` — the numeric +// `workflow_id` field was removed when the clone-portable identifier rolled out. export function fetchRdbmsLookupRecords(templateUuid: string, q: RdbmsLookupQuery): Promise { return request("POST", `/app/${APP_ID}/rdbms-templates/${encodeURIComponent(templateUuid)}/records`, { - workflow_id: q.workflowId, + workflow_uuid: q.workflowUuid, activity_id: q.activityId, field_id: q.fieldId, + instance_id: q.instanceId || undefined, limit: q.limit ?? 1000, offset: q.offset ?? 0, search: q.search ?? "", + form_data: q.formData ?? {}, + }); +} + +// --------------------------------------------------------------------------- +// WF Lookup records (cross-workflow references) & dataset-backed options +// --------------------------------------------------------------------------- + +/** Fetch records for a wf_lookup field. The server resolves the field's lookup + * config + filter rules from activityId/fieldId against formData. */ +export function wfLookupRecords( + workflowUuid: string, + opts: { + activityId: string; + fieldId: string; + formData?: Record; + search?: string; + limit?: number; + offset?: number; + }, +): Promise<{ data?: Record[]; records?: Record[] } | Record[]> { + return request("POST", `/app/${APP_ID}/wf-lookup/records`, { + workflow_uuid: workflowUuid, + activity_id: opts.activityId, + field_id: opts.fieldId, + form_data: opts.formData ?? {}, + search: opts.search ?? "", + limit: opts.limit ?? 100, + offset: opts.offset ?? 0, + }); +} + +/** Fetch options for a dataset-backed select. The server applies the activity's + * rules against formData, so e.g. City options come back scoped to the chosen + * State. Returns {label, value} rows (plus the raw dataset row). */ +export function datasetOptions( + workflowUuid: string, + opts: { + activityId: string; + fieldId: string; + search?: string; + instanceId?: string; + limit?: number; + formData?: Record; + }, +): Promise<{ options: Array<{ label: string; value: string; _raw?: Record }> }> { + return request("POST", `/app/${APP_ID}/dataset-options`, { + workflow_uuid: 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 ?? {}, }); } diff --git a/src/components/RecordViewTable.tsx b/src/components/RecordViewTable.tsx index e2093a7..adfe574 100644 --- a/src/components/RecordViewTable.tsx +++ b/src/components/RecordViewTable.tsx @@ -87,14 +87,12 @@ export default function RecordViewTable({ viewId, onRowClick, toolbarAction }: P sort_by: params.sort || undefined, sort_dir: params.dir, search: params.q || undefined, - // One entry per selected state. NOTE: this assumes the view-service ORs - // repeated filters on the same field_key — verify against the live API, - // since an AND would make any multi-select return zero rows. - filters: params.status.map((value) => ({ - field_key: STATE_FIELD, - value, - data_type: "text", - })), + // Multi-select ships as one filter with a `values` array, which the + // view-service ORs server-side. + filters: + params.status.length > 0 + ? [{ field_key: STATE_FIELD, values: params.status, data_type: "text" }] + : [], }), [params], ); diff --git a/src/config.ts b/src/config.ts index 71e5e38..8550248 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,8 +1,29 @@ -// P2P Invoice System (dev) — App 169, Org 47 +// P2P Invoice System (dev) — App 519, Org 900000103 // Mirrors the studio app 20 build but with dev's UUID-based UIDs. -export const ORG_ID = "47"; -export const APP_ID = "169"; +export const ORG_ID = "900000103"; +export const APP_ID = "519"; + +// Backend URL comes from the runtime config.js the frontgen server writes at +// placement time (window.__RUNTIME_CONFIG__.VITE_ZINO_API_URL) so one built +// artifact can be promoted across environments. The env fallback is dev-gated: +// a production build without a config.js must not silently serve the build +// environment's API URL. +declare global { + interface Window { __RUNTIME_CONFIG__?: Record } +} +const runtimeApiURL = + typeof window !== "undefined" ? window.__RUNTIME_CONFIG__?.VITE_ZINO_API_URL : undefined; + +// SAME_ORIGIN means "call the host that served this page": on a custom domain +// the same Ingress serves /usr and /app/, so the API is this origin. It is a +// sentinel rather than '' because the || below treats '' as absent. +export const BASE_URL = + runtimeApiURL === "SAME_ORIGIN" + ? window.location.origin.replace(/\/$/, "") + : runtimeApiURL || + (import.meta.env.DEV ? import.meta.env.VITE_ZINO_API_URL : undefined) || + "https://dev.getzino.in"; // Core-service expects the workflow UUID under `workflow_uuid`. export const WORKFLOW_ID = "0588b645-75f7-40e5-8251-e0761e4be632"; diff --git a/src/hooks/useAuth.ts b/src/hooks/useAuth.ts index a2d8698..fcbffcd 100644 --- a/src/hooks/useAuth.ts +++ b/src/hooks/useAuth.ts @@ -1,5 +1,6 @@ import { useState, useCallback } from "react"; import type { User } from "../zino-sdk/types"; +import { BASE_URL } from "../config"; const TOKEN_KEY = "p2p_token"; const USER_KEY = "p2p_user"; @@ -30,9 +31,7 @@ export function useAuth() { return { user, token, loading: false, error: null }; }); - const apiUrl = ( - import.meta.env.VITE_ZINO_API_URL || "https://studio.getzino.in" - ).replace(/\/+$/, ""); + const apiUrl = BASE_URL.replace(/\/+$/, ""); const login = useCallback( async ({ email, password, orgId }: LoginParams) => { @@ -44,7 +43,7 @@ export function useAuth() { body: JSON.stringify({ email, password, - ...(orgId ? { org_id: orgId } : {}), + ...(orgId ? { org_id: Number(orgId) } : {}), }), }); diff --git a/src/main.tsx b/src/main.tsx index 705969d..438f611 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -10,13 +10,19 @@ import { Toaster } from './components/ui/sonner' import './index.css' import App from './App' -const apiUrl = import.meta.env.VITE_ZINO_API_URL || 'https://studio.getzino.in' +import { BASE_URL } from './config' + +// Mount path comes from the document the frontgen server writes at +// placement time, so one built artifact serves any mount without a hardcoded +// slug. Falls back to "/" for local dev. +const routerBasename = + (document.querySelector('base')?.getAttribute('href') || '/').replace(/\/$/, '') || '/' createRoot(document.getElementById('root')!).render( - - + + {/* NuqsAdapter must sit inside the router — it reads and writes the router's search params, so table state can live in the URL. */} diff --git a/vite.config.ts b/vite.config.ts index ab7e91f..76e0187 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -3,7 +3,7 @@ import react from '@vitejs/plugin-react' import tailwindcss from '@tailwindcss/vite' import { fileURLToPath, URL } from 'node:url' -const devTarget = 'https://studio.getzino.in' +const devTarget = 'https://dev.getzino.in' const proxy = (target: string) => ({ target, changeOrigin: true, secure: false }) export default defineConfig({ @@ -11,7 +11,9 @@ export default defineConfig({ resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) }, }, - base: process.env.VITE_BASE_URL ?? '/p2p/', + // Relative base so the built artifact is mount-path agnostic; the target + // frontgen writes the real into index.html at placement time. + base: './', server: { port: 3007, proxy: {