Compare commits

...

10 Commits

Author SHA1 Message Date
suryacp23
eaf0ee3d63 based on the dsr option is fetched from the backend 2026-07-30 13:09:35 +05:30
suryacp23
8640629005 app user creation made 2026-07-30 12:34:55 +05:30
suryacp23
8f69af24ba added create user 2026-07-30 12:32:09 +05:30
suryacp23
dc4a119a7a added role based access 2026-07-30 10:36:58 +05:30
suryacp23
f9ea463ea8 used layout effect for the select 2026-07-29 17:50:27 +05:30
suryacp23
b6f2872703 performed field check fix 2026-07-29 17:33:39 +05:30
suryacp23
d98044692f edit option for the populated same product 2026-07-29 17:04:00 +05:30
suryacp23
30339b0443 added logvisit form route based storeselection 2026-07-29 16:06:02 +05:30
suryacp23
0ab9be85a6 added the work pre punchout prefill 2026-07-29 12:42:26 +05:30
suryacp23
8ad60001fe submenu of the header fix 2026-07-29 11:27:49 +05:30
30 changed files with 1467 additions and 186 deletions

View File

@ -2,17 +2,17 @@ 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 { MyOrdersPage } from './screens/MyOrdersPage'
import { CallsPage } from './screens/CallsPage'
import { MyCallsPage } from './screens/MyCallsPage'
import { StoresPage } from './screens/StoresPage'
import { DailyLogsPage } from './screens/DailyLogsPage'
import { MyDailyLogsPage } from './screens/MyDailyLogsPage'
import { DailySalesReportPage } from './screens/DailySalesReportPage'
import { ReportPage } from './screens/ReportPage'
import { DatasetsPage } from './screens/admin/DatasetsPage'
import { DatasetItemsPage } from './screens/admin/DatasetItemsPage'
import { ProtectedRoute, getDefaultRoute } from './auth/ProtectedRoute'
import { useAuth } from './auth/context'
function RootRedirect() {
const { roles, isAdmin } = useAuth();
return <Navigate to={getDefaultRoute(roles, isAdmin)} replace />;
}
import { routeConfig } from './routesConfig'
function App() {
return (
@ -21,35 +21,32 @@ function App() {
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route element={<ConsoleLayout />}>
<Route path="orders" element={<OrdersPage />} />
<Route path="orders/:instanceId" element={<OrdersPage />} />
{routeConfig.map((route, i) => {
const path = route.path.startsWith('/') ? route.path.substring(1) : route.path;
// Maintain nested route for Dataset items specifically
if (path === 'admin/datasets') {
return (
<Route
key={i}
path={path}
element={<ProtectedRoute adminOnly={route.adminOnly} roles={route.roles}>{route.element}</ProtectedRoute>}
>
<Route path=":id" element={<DatasetItemsPage />} />
</Route>
);
}
<Route path="my-orders" element={<MyOrdersPage />} />
<Route path="my-orders/:instanceId" element={<MyOrdersPage />} />
<Route path="calls" element={<CallsPage />} />
<Route path="calls/:instanceId" element={<CallsPage />} />
<Route path="my-calls" element={<MyCallsPage />} />
<Route path="my-calls/:instanceId" element={<MyCallsPage />} />
<Route path="stores" element={<StoresPage />} />
<Route path="stores/:instanceId" element={<StoresPage />} />
<Route path="daily" element={<DailyLogsPage />} />
<Route path="daily/:instanceId" element={<DailyLogsPage />} />
<Route path="my-daily" element={<MyDailyLogsPage />} />
<Route path="my-daily/:instanceId" element={<MyDailyLogsPage />} />
<Route path="reports/:reportType" element={<ReportPage />} />
<Route path="sales-report" element={<DailySalesReportPage />} />
return (
<Route
key={i}
path={path}
element={<ProtectedRoute roles={route.roles} adminOnly={route.adminOnly}>{route.element}</ProtectedRoute>}
/>
);
})}
<Route path="admin/datasets" element={<DatasetsPage />}>
<Route path=":id" element={<DatasetItemsPage />} />
</Route>
<Route path="*" element={<Navigate to="/orders" replace />} />
<Route path="*" element={<RootRedirect />} />
</Route>
</Routes>
</BrowserRouter>

View File

@ -122,6 +122,17 @@ export class ZinoClient {
this.setToken(null);
}
async getMe(): Promise<User> {
const res = await this.request<User>('GET', `/usr/app/${APP_ID}/me`);
if (this.user) {
this.user = { ...this.user, ...res };
if (typeof window !== 'undefined') {
localStorage.setItem(USER_KEY, JSON.stringify(this.user));
}
}
return res;
}
/** Decode the persisted JWT into a User (no network) or use the saved user. */
currentUser(): User | null {
if (!this.token) return null;
@ -133,6 +144,7 @@ export class ZinoClient {
org_id: String(p.org_id ?? ''),
name: p.name ?? '',
email: p.email ?? '',
mobile: String(p.mobile ?? p.mobile_number ?? p.phone ?? p.phone_number ?? p.user_mobile ?? ''),
roles: p.roles ?? [],
groups: p.groups ?? [],
};

View File

@ -21,15 +21,22 @@ export function clientFor(slug: WorkflowSlug): ZinoClient {
export const orderBookingClient = clientFor('orderBooking');
export const storeClient = clientFor('store');
export const dailyReportsClient = clientFor('dailyReports');
export const userClient = clientFor('user');
// 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];
const ALL = [orderBookingClient, storeClient, dailyReportsClient, userClient];
/** 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));
try {
const me = await orderBookingClient.getMe();
res.user = { ...res.user, ...me };
} catch (e) {
console.error('Failed to fetch user profile:', e);
}
return res;
}

View File

@ -177,11 +177,38 @@ export const DAILY_REPORTS = {
},
} as const;
// --- User Management (src/docs/api/user.md) ---
export const USER = {
workflowUuid: '9874af12-78b7-4730-834a-19a32221f3aa',
versionUuid: '', // No version UUID provided, server will use latest
activities: {
ADD_USER: {
uid: '3a8f132e-8c63-4d4d-b466-67595d345a99', // init
fields: {
name: 'name', // text
email: 'email', // email
password: 'password', // password
},
},
EDIT_USER: {
uid: '3091530c-4315-42b9-b53b-a148f2c54a81',
fields: {
name: 'name_3', // text
email: 'email_3', // email
},
},
},
recordViews: {
SALES_OFFICERS: '406f89d7-09f6-42ba-ba35-84334ccb6204',
},
} as const;
// All workflows keyed by slug, for generic lookup.
export const WORKFLOWS = {
orderBooking: ORDER_BOOKING,
store: STORE,
dailyReports: DAILY_REPORTS,
user: USER,
} as const;
// --- Daily Sales Report Constants ---
@ -217,6 +244,8 @@ export const SALES_REPORT_SO_NAMES = [
export const PIPELINE = {
endpoints: {
nearestStores: '/api/papi2/nearest-stores',
dailySalesReport: '/api/papi2/daily-sales-report'
dailySalesReport: '/api/papi2/daily-sales-report',
productiveCallSummary: '/api/papi2/productive-call-summary',
salesOfficers: '/api/papi2/sales-officers'
}
};

View File

@ -10,8 +10,10 @@ export interface User {
org_id: string;
name: string;
email: string;
mobile?: string;
roles: string[];
groups: string[];
is_admin?: boolean;
}
export interface LoginResponse {

View File

@ -1,5 +1,5 @@
import { useState, type ReactNode } from 'react';
import { loginAll, logoutAll, currentToken } from '../api/clients';
import { useState, type ReactNode, useEffect } from 'react';
import { loginAll, logoutAll, currentToken, orderBookingClient } from '../api/clients';
import { AuthCtx, type AuthValue } from './context';
export function AuthProvider({ children }: { children: ReactNode }) {
@ -7,9 +7,27 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const [userEmail, setUserEmail] = useState<string | null>(() => {
return typeof window !== 'undefined' ? localStorage.getItem('krishna_sales_user_email') : null;
});
const [isAdmin, setIsAdmin] = useState<boolean>(() => {
return orderBookingClient.currentUser()?.is_admin ?? false;
});
const [roles, setRoles] = useState<string[]>(() => {
return orderBookingClient.currentUser()?.roles ?? [];
});
// If we are authenticated but don't have isAdmin from cache, we might want to fetch it
useEffect(() => {
if (authed && (!isAdmin || roles.length === 0)) {
orderBookingClient.getMe().then(me => {
if (me.is_admin) setIsAdmin(true);
if (me.roles) setRoles(me.roles);
}).catch(console.error);
}
}, [authed]);
const value: AuthValue = {
authed,
isAdmin,
roles,
userEmail,
login: async (email, password, orgId) => {
const res = await loginAll(email, password, orgId);
@ -18,11 +36,15 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setUserEmail(res.user.email);
localStorage.setItem('krishna_sales_user_email', res.user.email);
}
setIsAdmin(!!res.user?.is_admin);
setRoles(res.user?.roles ?? []);
},
logout: () => {
logoutAll();
setAuthed(false);
setUserEmail(null);
setIsAdmin(false);
setRoles([]);
localStorage.removeItem('krishna_sales_user_email');
},
};

View File

@ -0,0 +1,63 @@
import { useNavigate } from 'react-router-dom';
import { ShieldAlert } from 'lucide-react';
import { useAuth } from './context';
import { Button } from '../components/buttons';
import { Card } from '../components/reusable';
export function getDefaultRoute(roles: string[], isAdmin: boolean) {
if (isAdmin) return '/orders';
if (roles.includes('Manager')) return '/orders';
if (roles.includes('Sales Officer')) return '/my-orders';
return '/stores';
}
function AccessDenied() {
const { roles, isAdmin } = useAuth();
const navigate = useNavigate();
return (
<div className="flex flex-col items-center justify-center min-h-[500px] h-full p-4 m-4">
<Card
className="max-w-md w-full shadow-sm mx-auto"
bodyClassName="flex flex-col items-center justify-center text-center p-8"
pad={false}
>
<ShieldAlert className="w-16 h-16 text-red-500 mb-4 opacity-90 mx-auto" />
<h2 className="text-2xl font-bold text-gray-800 mb-2">Access Denied</h2>
<p className="text-gray-500 mb-8">
You don't have permission to view this page. If you believe this is an error, please contact your administrator.
</p>
<Button
variant="primary"
onClick={() => navigate(getDefaultRoute(roles, isAdmin))}
>
Return to Homepage
</Button>
</Card>
</div>
);
}
export function ProtectedRoute({
children,
roles,
adminOnly
}: {
children: React.ReactNode,
roles?: string[],
adminOnly?: boolean
}) {
const { roles: userRoles, isAdmin } = useAuth();
if (adminOnly && !isAdmin) {
return <AccessDenied />;
}
if (roles && roles.length > 0) {
const hasRole = roles.some(role => userRoles.includes(role));
if (!hasRole) {
return <AccessDenied />;
}
}
return <>{children}</>;
}

View File

@ -2,6 +2,8 @@ import { createContext, useContext } from 'react';
export interface AuthValue {
authed: boolean;
isAdmin: boolean;
roles: string[];
userEmail: string | null;
login: (email: string, password: string, orgId?: string) => Promise<void>;
logout: () => void;

View File

@ -17,7 +17,8 @@ import {
WfLookupField,
RadioField,
} from './fields';
import { ORDER_BOOKING, STORE } from '../../api/config';
import { ORDER_BOOKING, STORE, DAILY_REPORTS, PIPELINE } from '../../api/config';
import { isCategoryColumn, isProductColumn } from './fields/gridUtils';
export interface DynamicFormProps {
client: ZinoClient;
@ -52,7 +53,7 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId:
let mounted = true;
setLoading(true);
client.formSchema(currentActivityId, currentInstanceId)
.then(res => {
.then(async (res) => {
if (mounted) {
setSchema(res);
const defaultValues: Record<string, unknown> = {};
@ -78,15 +79,54 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId:
res.fields.filter(f => f.data_type === 'image' || f.data_type === 'file').map(f => f.id)
);
const mapPrefillData = (sourceData: Record<string, unknown>) => {
res.fields.forEach(f => {
if (imageFieldIds.has(f.id)) return;
const getBaseId = (id: string) => id.replace(/_\d+$/, '');
if (sourceData[f.id] !== undefined) {
defaultValues[f.id] = sourceData[f.id];
} else if (f.mapped_workflow_field && sourceData[f.mapped_workflow_field] !== undefined) {
defaultValues[f.id] = sourceData[f.mapped_workflow_field];
} else if (sourceData[getBaseId(f.id)] !== undefined) {
defaultValues[f.id] = sourceData[getBaseId(f.id)];
} else {
const matchingDataKey = Object.keys(sourceData).find(k => getBaseId(k) === f.id || getBaseId(k) === getBaseId(f.id) || k === f.mapped_workflow_field);
if (matchingDataKey) {
defaultValues[f.id] = sourceData[matchingDataKey];
}
}
if (f.data_type === 'grid' && Array.isArray(defaultValues[f.id])) {
defaultValues[f.id] = (defaultValues[f.id] as Record<string, unknown>[]).map(row => {
const newRow: Record<string, unknown> = { ...row };
f.columns?.forEach(col => {
const colBaseId = getBaseId(col.id);
if (row[col.id] !== undefined) {
newRow[col.id] = row[col.id];
} else if (col.mapped_workflow_field && row[col.mapped_workflow_field] !== undefined) {
newRow[col.id] = row[col.mapped_workflow_field];
} else if (row[colBaseId] !== undefined) {
newRow[col.id] = row[colBaseId];
} else {
const matchingRowKey = Object.keys(row).find(k => getBaseId(k) === col.id || getBaseId(k) === colBaseId || k === col.mapped_workflow_field);
if (matchingRowKey) {
newRow[col.id] = row[matchingRowKey];
}
}
});
return newRow;
});
}
});
};
if (!ignorePrefill) {
if (res.prefill_data) {
Object.entries(res.prefill_data).forEach(([k, v]) => {
if (!imageFieldIds.has(k)) defaultValues[k] = v;
});
if (res.prefill_data && Object.keys(res.prefill_data).length > 0) {
mapPrefillData(res.prefill_data);
} else if (res.data) {
Object.entries(res.data).forEach(([k, v]) => {
if (!imageFieldIds.has(k)) defaultValues[k] = v;
});
mapPrefillData(res.data);
}
}
@ -101,6 +141,81 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId:
if (!imageFieldIds.has(k)) defaultValues[k] = v;
});
}
if (currentActivityId === DAILY_REPORTS.activities.PUNCH_OUT.uid) {
const user = client.currentUser();
const userEmail = user?.email;
if (userEmail) {
try {
const headers: Record<string, string> = {
'templateid': '192',
'x-pipeline-version': 'draft',
'orgid': '57',
'groupid': '25'
};
const summaryRes = await client.request<any>(
'POST',
PIPELINE.endpoints.productiveCallSummary,
{ email: userEmail },
headers
);
let summaryData = summaryRes.data || summaryRes;
if (Array.isArray(summaryData)) {
summaryData = summaryData[0] || {};
}
// Map the API response keys to match the form field keys
if (summaryData.productive_call !== undefined) {
summaryData.total_productive_calls = summaryData.productive_call;
}
if (summaryData.non_productive_call !== undefined) {
summaryData.total_non_productive_calls = summaryData.non_productive_call;
}
mapPrefillData(summaryData);
} catch (e) {
console.warn('Failed to load productive call summary', e);
}
}
}
if (currentActivityId === ORDER_BOOKING.activities.LOG_VISIT.uid) {
const user = client.currentUser();
const userMobile = user?.mobile || localStorage.getItem('krishna_sales_user_mobile') || user?.email;
if (userMobile) {
try {
const headers: Record<string, string> = {
'templateid': '192',
'x-pipeline-version': 'draft',
'orgid': '57',
'groupid': '25'
};
const summaryRes = await client.request<any>(
'POST',
PIPELINE.endpoints.productiveCallSummary,
{ mobile: userMobile },
headers
);
let summaryData = summaryRes.data || summaryRes;
if (Array.isArray(summaryData)) {
summaryData = summaryData[0] || {};
}
if (summaryData.productive_call !== undefined) {
summaryData.total_productive_calls = summaryData.productive_call;
}
if (summaryData.non_productive_call !== undefined) {
summaryData.total_non_productive_calls = summaryData.non_productive_call;
}
mapPrefillData(summaryData);
} catch (e) {
console.warn('Failed to load productive call summary for Log Visit', e);
}
}
}
setValues(defaultValues);
setLoading(false);
@ -271,11 +386,11 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId:
if (cat && prod) {
const key = `${cat}::${prod}`;
if (indexMap.has(key)) {
if (indexMap.has(key) && currentBags > 0) {
const targetIdx = indexMap.get(key)!;
const existingRow = merged[targetIdx];
const existingBags = Number(getGridVal(existingRow, ['bags', 'quantity'])) || 0;
const addedBags = currentBags > 0 ? currentBags : 1;
const addedBags = currentBags;
const newBags = existingBags + addedBags;
const skuVal = Number(getGridVal(existingRow, ['sku'], ['code']) || currentSku || 0);
@ -286,7 +401,7 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId:
row_kgs: skuVal * newBags,
};
continue;
} else {
} else if (!indexMap.has(key)) {
indexMap.set(key, merged.length);
}
}
@ -483,13 +598,113 @@ export function DynamicForm({ client, activityId: initialActivityId, instanceId:
payload[f.id] = [fileMeta];
} else if (f.data_type.startsWith('grid') || f.data_type === 'smart_grid') {
const gridRows = Array.isArray(val) ? val : [];
const cleanRows = gridRows.filter(r => {
return Object.keys(r).some(k => {
const v = r[k];
return v !== undefined && v !== null && String(v).trim() !== '';
const getFormattedGridVal = (colKey: string, rawVal: unknown, colDef?: FormScreenField) => {
if (rawVal === undefined || rawVal === null || String(rawVal).trim() === '') return undefined;
const keyLower = colKey.toLowerCase();
const colNameLower = (colDef?.name || '').toLowerCase();
const dataType = colDef?.data_type;
// 1. Explicit number data_type from form screen API
if (dataType === 'number') {
const num = Number(rawVal);
return !isNaN(num) ? num : rawVal;
}
// 2. Explicit string/select/id_gen data_type from API or SKU/code identifier columns
const isStringCol = dataType === 'select' ||
dataType === 'text' ||
dataType === 'string' ||
dataType === 'id_gen' ||
keyLower.includes('sku') ||
keyLower.includes('code') ||
keyLower.includes('br_code') ||
keyLower === 'br' ||
colNameLower.includes('sku') ||
colNameLower.includes('code');
if (isStringCol) {
return String(rawVal);
}
// 3. Bags / Quantity / Row Kgs quantity columns without explicit text colDef
const isNumericQuantity = keyLower === 'bags' ||
keyLower === 'quantity' ||
keyLower === 'row_kgs' ||
colNameLower === 'bags' ||
colNameLower === 'quantity';
if (isNumericQuantity) {
const num = Number(rawVal);
return !isNaN(num) ? num : rawVal;
}
return rawVal;
};
const cleanRows = gridRows
.filter(r => {
return Object.keys(r).some(k => {
const v = r[k];
return v !== undefined && v !== null && String(v).trim() !== '';
});
})
.map(r => {
const formattedRow: Record<string, unknown> = {};
// 1. Process all existing keys in row r
for (const [k, v] of Object.entries(r)) {
if (v === undefined || v === null || String(v).trim() === '') continue;
const colDef = f.columns?.find(c =>
c.id === k ||
(c.name || '').toLowerCase() === k.toLowerCase() ||
c.mapped_workflow_field === k
);
const formattedVal = getFormattedGridVal(k, v, colDef);
if (formattedVal !== undefined) {
formattedRow[k] = formattedVal;
if (colDef && colDef.id && colDef.id !== k) {
formattedRow[colDef.id] = formattedVal;
}
}
}
// 2. Also map any schema defined columns from f.columns
if (f.columns && f.columns.length > 0) {
for (const col of f.columns) {
if (formattedRow[col.id] === undefined) {
const matchKey = Object.keys(r).find(rk =>
rk.toLowerCase() === col.id.toLowerCase() ||
rk.toLowerCase() === (col.name || '').toLowerCase() ||
(col.mapped_workflow_field && rk.toLowerCase() === col.mapped_workflow_field.toLowerCase()) ||
(isCategoryColumn(col.id, col.name) && (rk.toLowerCase().includes('category') || rk === 'cat')) ||
(isProductColumn(col.id, col.name) && (rk.toLowerCase().includes('product') || rk === 'productName')) ||
(col.name?.toLowerCase().includes('bags') && (rk.toLowerCase().includes('bags') || rk.toLowerCase().includes('quantity')))
);
if (matchKey && r[matchKey] !== undefined && r[matchKey] !== null && String(r[matchKey]).trim() !== '') {
const formattedVal = getFormattedGridVal(matchKey, r[matchKey], col);
if (formattedVal !== undefined) {
formattedRow[col.id] = formattedVal;
}
}
}
}
}
return formattedRow;
});
});
payload[f.id] = cleanRows;
} else if (f.data_type === 'number') {
if (val !== undefined && val !== null && val !== '') {
const num = Number(val);
payload[f.id] = !isNaN(num) ? num : val;
} else {
payload[f.id] = val;
}
} else {
payload[f.id] = val;
}

View File

@ -0,0 +1,555 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import type { ZinoClient } from '../../api/client';
import type { FormScreenResponse } from '../../api/types';
import { ORDER_BOOKING, PIPELINE } from '../../api/config';
import { Button } from '../buttons/Button';
import { Select } from '../reusable/Select';
import { DateField, TimeField, FileInput, TextField } from './fields';
import { Spinner } from '../reusable/Spinner';
import { DynamicForm } from './DynamicForm';
export interface LogVisitFormProps {
client: ZinoClient;
onSuccess?: () => void;
onCancel?: () => void;
onActivityChange?: (name: string) => void;
}
// Utility to clean empty values from form data before sending API requests
const cleanFormData = (data: Record<string, any>) => {
const result: Record<string, any> = {};
Object.entries(data).forEach(([k, v]) => {
if (v !== '' && v !== null && v !== undefined && !(Array.isArray(v) && v.length === 0)) {
result[k] = v;
}
});
return result;
};
/**
* Dedicated form component for the Log Visit activity.
* Calls client.formSchema() to retrieve form screen fields dynamically,
* pre-fills daily_log & route_code from initial daily log lookup,
* lazy-fetches select_store options after daily log completion,
* handles mobile pipeline prefill,
* AND wires activity chaining seamlessly into DynamicForm for subsequent activities.
*/
export function LogVisitForm({ client, onSuccess, onCancel, onActivityChange }: LogVisitFormProps) {
const activityId = ORDER_BOOKING.activities.LOG_VISIT.uid;
const todayStr = new Date().toISOString().split('T')[0];
const nowTimeStr = new Date().toTimeString().split(' ')[0].substring(0, 5);
const [schema, setSchema] = useState<FormScreenResponse | null>(null);
const schemaRef = useRef<FormScreenResponse | null>(null);
useEffect(() => { schemaRef.current = schema; }, [schema]);
const [loadingSchema, setLoadingSchema] = useState(true);
const [values, setValues] = useState<Record<string, any>>({
date_of_visit: todayStr,
time_of_visit: nowTimeStr,
select_store: '',
upload_image: [],
daily_log: '',
route_code: '',
});
const valuesRef = useRef(values);
useEffect(() => {
valuesRef.current = values;
}, [values]);
const [storeOptions, setStoreOptions] = useState<{ value: string; label: string; _raw?: any }[]>([]);
const [fetchingDailyLog, setFetchingDailyLog] = useState(false);
const [fetchingStores, setFetchingStores] = useState(false);
const fetchingStoresRef = useRef(false);
const [chainedActivity, setChainedActivity] = useState<{
activityId: string;
instanceId?: number | string;
prefillData?: Record<string, unknown>;
} | null>(null);
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
// 1. Fetch form schema from API (/app/434/view/form-screens) & Mobile Pipeline prefill
useEffect(() => {
let mounted = true;
setLoadingSchema(true);
client.formSchema(activityId)
.then(async (res) => {
if (!mounted) return;
setSchema(res);
schemaRef.current = res;
const initialValues: Record<string, any> = {
date_of_visit: todayStr,
time_of_visit: nowTimeStr,
select_store: '',
upload_image: [],
daily_log: '',
route_code: '',
};
if (res.field_defaults) {
Object.entries(res.field_defaults).forEach(([fieldId, def]) => {
if (def.value != null) {
initialValues[fieldId] = def.value;
} else if (def.prefill) {
if (def.prefill.value === 'current_date') {
initialValues[fieldId] = new Date().toISOString().split('T')[0];
} else if (def.prefill.value === 'current_time') {
initialValues[fieldId] = new Date().toTimeString().split(' ')[0].substring(0, 5);
} else if (def.prefill.value === 'current_user_id') {
const user = client.currentUser();
initialValues[fieldId] = user ? Number(user.id) : '';
} else {
initialValues[fieldId] = def.prefill.value;
}
}
});
}
if (res.prefill_data && Object.keys(res.prefill_data).length > 0) {
Object.assign(initialValues, res.prefill_data);
} else if (res.data && Object.keys(res.data).length > 0) {
Object.assign(initialValues, res.data);
}
// Execute mobile pipeline prefill
const user = client.currentUser();
const userMobile = user?.mobile || localStorage.getItem('krishna_sales_user_mobile') || user?.email;
if (userMobile) {
try {
const headers: Record<string, string> = {
'templateid': '192',
'x-pipeline-version': 'draft',
'orgid': '57',
'groupid': '25'
};
const summaryRes = await client.request<any>(
'POST',
PIPELINE.endpoints.productiveCallSummary,
{ mobile: userMobile },
headers
);
let summaryData = summaryRes.data || summaryRes;
if (Array.isArray(summaryData)) {
summaryData = summaryData[0] || {};
}
if (summaryData.productive_call !== undefined) {
summaryData.total_productive_calls = summaryData.productive_call;
}
if (summaryData.non_productive_call !== undefined) {
summaryData.total_non_productive_calls = summaryData.non_productive_call;
}
Object.assign(initialValues, summaryData);
} catch (e) {
console.warn('Failed to load productive call summary for Log Visit', e);
}
}
setValues(prev => {
const next = { ...initialValues, ...prev };
valuesRef.current = next;
return next;
});
})
.catch(err => {
console.error('Failed to load Log Visit form schema:', err);
})
.finally(() => {
if (mounted) setLoadingSchema(false);
});
return () => { mounted = false; };
}, [client, activityId, todayStr, nowTimeStr]);
// 2. Helper to fetch select_store options ONLY using updated prefilled formData
const fetchStoreOptions = useCallback(async (formDataOverride?: Record<string, any>) => {
if (fetchingStoresRef.current) return;
fetchingStoresRef.current = true;
setFetchingStores(true);
try {
const formDataToSend = cleanFormData(formDataOverride || valuesRef.current);
console.log('[LogVisitForm] Executing select_store lookup with prefilled formData:', formDataToSend);
const lookupRes = await client.wfLookupRecords({
activityId,
fieldId: 'select_store',
formData: formDataToSend,
limit: 200,
});
const arr = Array.isArray(lookupRes)
? lookupRes
: lookupRes?.data || lookupRes?.records || [];
// Find display_fields configured in schema for select_store
const selectStoreField = schemaRef.current?.fields.find(
f => f.id === 'select_store' || f.uid === 'field_1783057892381' || f.name.toLowerCase().includes('select store')
);
const displayFields = (selectStoreField?.properties?.wf_lookup_config as any)?.display_fields || [];
const opts = arr.map((row: any) => {
let labelText = '';
if (displayFields.length > 0) {
const labelParts = displayFields
.map((df: any) => row[df.field_id])
.filter((v: any) => v != null && v !== '');
if (labelParts.length > 0) {
labelText = labelParts.join(' - ');
}
}
if (!labelText) {
const storeName = row.business_name_2 || row.store_name || row.name || row.store;
const storeCode = row.store_code || row.code;
labelText = storeName
? `${storeCode ? `${storeCode} - ` : ''}${storeName}`
: `Store #${row.instance_id || row.id}`;
}
return {
value: String(row.instance_id || row.id),
label: labelText,
_raw: row,
};
});
setStoreOptions(opts);
} catch (e) {
console.error('Failed to load select_store options:', e);
} finally {
setFetchingStores(false);
fetchingStoresRef.current = false;
}
}, [client, activityId]);
// 3. Fetch Daily Log lookup data initially & prefill form state
const loadDailyLogData = useCallback(async (dateVal: string, timeVal: string) => {
setFetchingDailyLog(true);
try {
const payload = cleanFormData({
date_of_visit: dateVal,
time_of_visit: timeVal,
});
console.log('[LogVisitForm] Fetching initial daily_log lookup with payload:', payload);
const dailyLogLookupRes = await client.wfLookupRecords({
activityId,
fieldId: 'daily_log',
formData: payload,
limit: 200,
});
const arr = Array.isArray(dailyLogLookupRes)
? dailyLogLookupRes
: dailyLogLookupRes?.data || dailyLogLookupRes?.records || [];
if (arr.length > 0) {
const firstLog = arr[0];
console.log('[LogVisitForm] Successfully fetched daily log record:', firstLog);
const dailyLogInstanceId = String(firstLog.instance_id || firstLog.id || '');
const routeCode = String(
firstLog.route_code ||
firstLog.route_code_1 ||
firstLog.route_code_2 ||
firstLog.route_code_3 ||
firstLog.route ||
''
);
const updated = { ...valuesRef.current };
// Copy raw fields from daily log into state
Object.keys(firstLog).forEach(key => {
if (firstLog[key] != null && firstLog[key] !== '') {
updated[key] = firstLog[key];
}
});
if (dailyLogInstanceId) {
updated['daily_log'] = dailyLogInstanceId;
updated['field_1785225403902'] = dailyLogInstanceId;
updated['instance_id'] = firstLog.instance_id || firstLog.id;
}
if (routeCode) {
updated['route_code'] = routeCode;
updated['route_code_1'] = routeCode;
updated['route_code_2'] = routeCode;
updated['route_code_3'] = routeCode;
updated['field_1785311859486'] = routeCode;
}
valuesRef.current = updated;
setValues(updated);
console.log('[LogVisitForm] Daily log prefill complete:', updated);
} else {
console.warn('[LogVisitForm] No daily log records found for date/time:', dateVal, timeVal);
}
} catch (e) {
console.warn('Failed to load daily_log lookup for Log Visit', e);
} finally {
setFetchingDailyLog(false);
}
}, [client, activityId]);
useEffect(() => {
loadDailyLogData(values.date_of_visit, values.time_of_visit);
}, [loadDailyLogData, values.date_of_visit, values.time_of_visit]);
const handleStoreDropdownOpen = () => {
fetchStoreOptions();
};
const handleStoreSelect = (fieldId: string, val: string) => {
const selectedOpt = storeOptions.find(o => String(o.value) === String(val));
const rawRow = selectedOpt?._raw || {};
setValues(prev => {
const next: Record<string, any> = { ...prev, [fieldId]: val, select_store: val, field_1: val };
// Map raw row fields into values
Object.keys(rawRow).forEach(key => {
next[key] = rawRow[key];
});
valuesRef.current = next;
return next;
});
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSubmitting(true);
setSubmitError(null);
try {
const validFields = schema?.fields || [];
const validFieldIds = new Set(validFields.map(f => f.id));
// Always include standard Log Visit field IDs
['select_store', 'date_of_visit', 'time_of_visit', 'upload_image', 'daily_log', 'route_code'].forEach(id => validFieldIds.add(id));
const payload: Record<string, any> = {};
validFieldIds.forEach(fieldId => {
const val = valuesRef.current[fieldId];
if (val !== undefined && val !== null && val !== '') {
payload[fieldId] = val;
}
});
// Handle image upload if present
let uploadedFiles: any[] = [];
const imgVal = valuesRef.current.upload_image;
const fileList = Array.isArray(imgVal) ? imgVal : (imgVal instanceof File ? [imgVal] : []);
for (const fileItem of fileList) {
if (fileItem instanceof File) {
const fileMeta = await client.uploadFile(fileItem, {
activityId,
fieldId: 'upload_image',
});
uploadedFiles.push(fileMeta);
} else {
uploadedFiles.push(fileItem);
}
}
payload['upload_image'] = uploadedFiles;
console.log('[LogVisitForm] Submitting clean startInstance payload:', payload);
const res: any = await client.startInstance(activityId, payload);
const chainSource = res?.activity_chain || schema?.activity_chain || [];
if (chainSource && chainSource.length > 0) {
const nextAct = chainSource[0];
console.log('[LogVisitForm] Activity chain detected, transitioning to DynamicForm:', nextAct);
onActivityChange?.(nextAct.activity_name);
setChainedActivity({
activityId: nextAct.activity_uid,
instanceId: res?.instance_id,
prefillData: { ...valuesRef.current },
});
} else {
onSuccess?.();
}
} catch (err: any) {
setSubmitError(err?.message || 'Failed to log visit.');
} finally {
setSubmitting(false);
}
};
// If activity chain triggered (e.g. Productivity of Visit), render DynamicForm seamlessly
if (chainedActivity) {
return (
<DynamicForm
client={client}
activityId={chainedActivity.activityId}
instanceId={chainedActivity.instanceId}
customPrefillData={chainedActivity.prefillData}
onSuccess={onSuccess}
onCancel={onCancel}
onActivityChange={onActivityChange}
/>
);
}
if (loadingSchema) {
return (
<div className="flex justify-center items-center py-12">
<Spinner size={24} label="Loading Log Visit form..." />
</div>
);
}
const fields = schema?.fields || [];
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
{fields.map(f => {
const fieldId = f.id;
const lowerId = fieldId.toLowerCase();
const lowerName = f.name.toLowerCase();
const isHidden = schema?.field_defaults?.[fieldId]?.hidden === true || (f.properties as any)?.hidden === true;
// Skip daily_log and route_code (or hidden fields) from visual rendering
if (
isHidden ||
lowerId === 'daily_log' ||
lowerId === 'field_1785225403902' ||
lowerId === 'route_code' ||
lowerId === 'field_1785311859486' ||
lowerName === 'daily log' ||
lowerName === 'route code'
) {
return null;
}
const type = f.data_type;
const val = values[fieldId];
const isDisabled = schema?.field_defaults?.[fieldId]?.disabled === true || f.properties?.disabled === true;
if (type === 'wf_lookup' || lowerId === 'select_store' || lowerName.includes('select store')) {
return (
<div key={fieldId} className="relative">
<Select
label={f.name}
required={f.mandatory}
value={(val as string) || ''}
options={[{ value: '', label: `Select ${f.name}...` }, ...storeOptions]}
onDropdownOpen={handleStoreDropdownOpen}
onChange={e => handleStoreSelect(fieldId, e.target.value)}
disabled={isDisabled}
/>
{fetchingStores && (
<div className="absolute right-3 top-9">
<Spinner size={16} />
</div>
)}
</div>
);
}
if (type.startsWith('date')) {
return (
<DateField
key={fieldId}
label={f.name}
required={f.mandatory}
disabled={isDisabled}
value={(val as string) || ''}
onChange={v => {
setValues(p => {
const next = { ...p, [fieldId]: v, date_of_visit: v };
valuesRef.current = next;
return next;
});
}}
/>
);
}
if (type.startsWith('time')) {
return (
<TimeField
key={fieldId}
label={f.name}
required={f.mandatory}
disabled={isDisabled}
value={(val as string) || ''}
onChange={v => {
setValues(p => {
const next = { ...p, [fieldId]: v, time_of_visit: v };
valuesRef.current = next;
return next;
});
}}
/>
);
}
if (type === 'image' || type === 'file') {
return (
<FileInput
key={fieldId}
label={f.name}
type={type}
required={f.mandatory}
value={val || []}
onChange={v => {
setValues(p => {
const next = { ...p, [fieldId]: v, upload_image: v };
valuesRef.current = next;
return next;
});
}}
/>
);
}
return (
<TextField
key={fieldId}
label={f.name}
required={f.mandatory}
type={type}
value={(val as string) || ''}
onChange={v => {
setValues(p => {
const next = { ...p, [fieldId]: v };
valuesRef.current = next;
return next;
});
}}
/>
);
})}
{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" disabled={Boolean(submitting || fetchingDailyLog)}>
{submitting ? 'Submitting...' : 'Log Visit'}
</Button>
</div>
</form>
);
}

View File

@ -3,11 +3,13 @@ import { Input } from '../../reusable/Input';
export function DateField({
label,
required,
disabled,
value,
onChange,
}: {
label: string;
required?: boolean;
disabled?: boolean;
value: string;
onChange: (val: string) => void;
}) {
@ -15,6 +17,7 @@ export function DateField({
<Input
label={label}
required={required}
disabled={disabled}
type="date"
value={value ?? ''}
onChange={(e) => onChange(e.target.value)}

View File

@ -78,12 +78,12 @@ export function OrderGrid({
if (cat && prod) {
const key = `${cat}::${prod}`;
if (indexMap.has(key)) {
if (indexMap.has(key) && currentBags > 0) {
const targetIdx = indexMap.get(key)!;
const existingRow = merged[targetIdx];
const existingBags = Number(existingRow[bagsColId] ?? existingRow.bags ?? existingRow.quantity ?? 0);
const addedBags = currentBags > 0 ? currentBags : 1;
const addedBags = currentBags;
const newBags = existingBags + addedBags;
const skuVal = Number(existingRow.sku || r.sku || 0);
@ -97,7 +97,7 @@ export function OrderGrid({
merged[targetIdx] = updatedRow;
continue;
} else {
} else if (!indexMap.has(key)) {
indexMap.set(key, merged.length);
}
}
@ -206,6 +206,7 @@ export function OrderGrid({
const isCatField = isCategoryColumn(fieldId, colName);
const isProdField = isProductColumn(fieldId, colName);
const isBagsField = colDef?.data_type === 'number' || fieldId.includes('bags') || fieldId.includes('quantity') || colName.toLowerCase().includes('bags');
if (isCatField) {
const catVal = String(val ?? '');
@ -222,6 +223,10 @@ export function OrderGrid({
row['br'] = '';
row['sku_code'] = '';
row['skucode'] = '';
row['bags'] = '';
row['quantity'] = '';
if (bagsCol) row[bagsCol.id] = '';
row['row_kgs'] = 0;
} else if (isProdField) {
const currentCat = String(row['category'] || row['product_category'] || (catCol ? row[catCol.id] : '') || '');
const prodOptions = getProductsForCategory(currentCat);
@ -242,25 +247,40 @@ export function OrderGrid({
row['productName'] = selectedName;
if (prodNameCol) row[prodNameCol.id] = setVal;
row['sku'] = sku;
row['sku'] = String(sku ?? '');
const skuCol = findColumn(columns, 'sku');
if (skuCol) row[skuCol.id] = sku;
if (skuCol) row[skuCol.id] = String(sku ?? '');
row['br'] = br;
row['br_code'] = br;
row['br'] = String(br ?? '');
row['br_code'] = String(br ?? '');
const brCol = findColumn(columns, 'br_code', 'br');
if (brCol) row[brCol.id] = br;
if (brCol) row[brCol.id] = String(br ?? '');
row['skucode'] = skucode;
row['sku_code'] = skucode;
row['skucode'] = String(skucode ?? '');
row['sku_code'] = String(skucode ?? '');
const skuCodeCol = findColumn(columns, 'sku_code', 'skucode');
if (skuCodeCol) row[skuCodeCol.id] = skucode;
if (skuCodeCol) row[skuCodeCol.id] = String(skucode ?? '');
if (cat) {
row['category'] = cat;
row['product_category'] = cat;
if (catCol) row[catCol.id] = cat;
}
const bagsVal = Number(row.bags ?? row.quantity ?? (bagsCol ? row[bagsCol.id] : 0)) || 0;
row['row_kgs'] = Number(sku || 0) * bagsVal;
} else if (isBagsField) {
const numericVal = val !== '' && val !== null && val !== undefined ? Number(val) : 0;
const isNumValid = !isNaN(numericVal);
const valToSave = isNumValid ? numericVal : 0;
row[fieldId] = valToSave;
row['bags'] = valToSave;
row['quantity'] = valToSave;
if (bagsCol) row[bagsCol.id] = valToSave;
const skuVal = Number(row.sku || 0);
row['row_kgs'] = skuVal * valToSave;
} else {
row[fieldId] = val;
}
@ -278,7 +298,7 @@ export function OrderGrid({
});
onChange(cleanRows);
}, [rows, columns, visibleColumns, prodNameCol, catCol, getProductsForCategory, onChange]);
}, [rows, columns, visibleColumns, prodNameCol, catCol, bagsCol, getProductsForCategory, onChange]);
const { calculatedBags, calculatedKgs } = useMemo(() => {
let bagsAcc = 0;

View File

@ -66,12 +66,12 @@ export function StorePotentialGrid({
const currentBags = Number(r[bagsColId] ?? r.bags ?? r.quantity ?? 0);
if (cat) {
if (indexMap.has(cat)) {
if (indexMap.has(cat) && currentBags > 0) {
const targetIdx = indexMap.get(cat)!;
const existingRow = merged[targetIdx];
const existingBags = Number(existingRow[bagsColId] ?? existingRow.bags ?? existingRow.quantity ?? 0);
const addedBags = currentBags > 0 ? currentBags : 1;
const addedBags = currentBags;
const newBags = existingBags + addedBags;
const updatedRow: Record<string, unknown> = {
@ -83,7 +83,7 @@ export function StorePotentialGrid({
merged[targetIdx] = updatedRow;
continue;
} else {
} else if (!indexMap.has(cat)) {
indexMap.set(cat, merged.length);
}
}
@ -124,19 +124,27 @@ export function StorePotentialGrid({
const colDef = columns.find((c) => c.id === fieldId);
const colName = colDef?.name || '';
const isCatField = isCategoryColumn(fieldId, colName);
const isBagsField = colDef?.data_type === 'number' || fieldId.includes('bags') || fieldId.includes('quantity') || colName.toLowerCase().includes('bags');
if (isCatField) {
const catVal = String(val ?? '');
row[fieldId] = catVal;
row['category'] = catVal;
row['product_category'] = catVal;
} else if (isBagsField) {
const numericVal = val !== '' && val !== null && val !== undefined ? Number(val) : 0;
const valToSave = !isNaN(numericVal) ? numericVal : 0;
row[fieldId] = valToSave;
row['bags'] = valToSave;
row['quantity'] = valToSave;
if (bagsCol) row[bagsCol.id] = valToSave;
} else {
row[fieldId] = val;
}
next[rowIdx] = row;
onChange(next);
}, [rows, columns, onChange]);
}, [rows, columns, bagsCol, onChange]);
return (
<div className="max-w-full bg-[var(--tiles-card-bg)] rounded-xl border border-border-default p-6 space-y-6 shadow-sm font-sans">

View File

@ -3,11 +3,13 @@ import { Input } from '../../reusable/Input';
export function TimeField({
label,
required,
disabled,
value,
onChange,
}: {
label: string;
required?: boolean;
disabled?: boolean;
value: string;
onChange: (val: string) => void;
}) {
@ -15,6 +17,7 @@ export function TimeField({
<Input
label={label}
required={required}
disabled={disabled}
type="time"
value={value ?? ''}
onChange={(e) => onChange(e.target.value)}

View File

@ -0,0 +1,2 @@
export * from './DynamicForm';
export * from './LogVisitForm';

View File

@ -1,4 +1,4 @@
import { useState, useRef, useEffect, useCallback, type SelectHTMLAttributes } from 'react';
import { useState, useRef, useEffect, useLayoutEffect, useCallback, type SelectHTMLAttributes } from 'react';
import { createPortal } from 'react-dom';
import { ChevronDown, Search, X, Check } from 'lucide-react';
import { cn } from '../../lib/cn';
@ -20,6 +20,7 @@ export interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
className?: string;
/** Disable search header filter if set to false */
searchable?: boolean;
onDropdownOpen?: () => void;
}
/** Custom searchable select component using React Portal to prevent container clipping. */
@ -33,6 +34,7 @@ export function Select({
disabled,
placeholder,
searchable = true,
onDropdownOpen,
...rest
}: SelectProps) {
const [isOpen, setIsOpen] = useState(false);
@ -61,10 +63,17 @@ export function Select({
const spaceBelow = window.innerHeight - rect.bottom;
const openUpwards = spaceBelow < dropdownHeight && rect.top > dropdownHeight;
const calculatedWidth = Math.max(rect.width, 280);
let leftPos = rect.left;
if (leftPos + calculatedWidth > window.innerWidth - 12) {
leftPos = Math.max(12, window.innerWidth - calculatedWidth - 12);
}
setDropdownStyle({
position: 'fixed',
left: `${rect.left}px`,
width: `${rect.width}px`,
left: `${leftPos}px`,
width: `${calculatedWidth}px`,
maxWidth: 'calc(100vw - 24px)',
zIndex: 999999,
...(openUpwards
? { bottom: `${window.innerHeight - rect.top + 4}px` }
@ -73,7 +82,7 @@ export function Select({
}
}, []);
useEffect(() => {
useLayoutEffect(() => {
if (isOpen) {
updatePosition();
const handleScrollOrResize = () => updatePosition();
@ -83,6 +92,8 @@ export function Select({
window.removeEventListener('resize', handleScrollOrResize);
window.removeEventListener('scroll', handleScrollOrResize, true);
};
} else {
setDropdownStyle({});
}
}, [isOpen, updatePosition]);
@ -100,7 +111,26 @@ export function Select({
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleSelect = (val: string) => {
const handleOpenToggle = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (!disabled) {
const next = !isOpen;
if (next) {
updatePosition();
onDropdownOpen?.();
} else {
setDropdownStyle({});
}
setIsOpen(next);
}
};
const handleSelect = (val: string, e?: React.MouseEvent) => {
if (e) {
e.preventDefault();
e.stopPropagation();
}
if (rest.onChange) {
const syntheticEvent = {
target: { value: val, name: rest.name, id: rest.id },
@ -123,7 +153,7 @@ export function Select({
{/* Trigger Box */}
<div
onClick={() => !disabled && setIsOpen(!isOpen)}
onClick={handleOpenToggle}
className={cn(
"relative bg-card rounded-md h-[42px] border px-3 flex items-center justify-between cursor-pointer transition-all duration-150 select-none",
disabled && "opacity-60 cursor-not-allowed bg-slate-50",
@ -137,7 +167,7 @@ export function Select({
</div>
{/* Portaled Dropdown Menu Overlay */}
{isOpen && !disabled && createPortal(
{isOpen && !disabled && Boolean(dropdownStyle.position) && createPortal(
<div
ref={dropdownRef}
style={dropdownStyle}
@ -158,7 +188,11 @@ export function Select({
{searchQuery && (
<button
type="button"
onClick={() => setSearchQuery('')}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setSearchQuery('');
}}
className="text-muted hover:text-strong p-0.5 rounded cursor-pointer"
>
<X size={12} />
@ -174,13 +208,14 @@ export function Select({
return (
<div
key={opt.value}
onClick={() => handleSelect(opt.value)}
onClick={(e) => handleSelect(opt.value, e)}
title={opt.label}
className={cn(
"px-3 py-2 text-sm flex items-center justify-between cursor-pointer transition-colors",
isSelected ? "bg-blue-50/50 text-blue-700 font-semibold" : "hover:bg-black/5 text-strong"
)}
>
<span className="truncate">{opt.label}</span>
<span className="flex-1 break-words text-xs sm:text-sm leading-normal mr-2">{opt.label}</span>
{isSelected && <Check size={14} className="text-blue-600 shrink-0 ml-2" />}
</div>
);
@ -263,13 +298,21 @@ export function MultiSelect({
const currentValues = Array.isArray(value) ? value : [];
const handleOpenToggle = () => {
const handleOpenToggle = (e?: React.MouseEvent) => {
if (e) {
e.preventDefault();
e.stopPropagation();
}
if (!disabled) {
if (!isOpen) {
const next = !isOpen;
if (next) {
setDraftValues(currentValues);
setSearchQuery('');
updatePosition();
} else {
setDropdownStyle({});
}
setIsOpen(!isOpen);
setIsOpen(next);
}
};
@ -284,10 +327,17 @@ export function MultiSelect({
const spaceBelow = window.innerHeight - rect.bottom;
const openUpwards = spaceBelow < dropdownHeight && rect.top > dropdownHeight;
const calculatedWidth = Math.max(rect.width, 280);
let leftPos = rect.left;
if (leftPos + calculatedWidth > window.innerWidth - 12) {
leftPos = Math.max(12, window.innerWidth - calculatedWidth - 12);
}
setDropdownStyle({
position: 'fixed',
left: `${rect.left}px`,
width: `${rect.width}px`,
left: `${leftPos}px`,
width: `${calculatedWidth}px`,
maxWidth: 'calc(100vw - 24px)',
zIndex: 999999,
...(openUpwards
? { bottom: `${window.innerHeight - rect.top + 4}px` }
@ -296,7 +346,7 @@ export function MultiSelect({
}
}, []);
useEffect(() => {
useLayoutEffect(() => {
if (isOpen) {
updatePosition();
const handleScrollOrResize = () => updatePosition();
@ -306,6 +356,8 @@ export function MultiSelect({
window.removeEventListener('resize', handleScrollOrResize);
window.removeEventListener('scroll', handleScrollOrResize, true);
};
} else {
setDropdownStyle({});
}
}, [isOpen, updatePosition]);
@ -323,18 +375,30 @@ export function MultiSelect({
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const toggleOption = (val: string) => {
const toggleOption = (val: string, e?: React.MouseEvent) => {
if (e) {
e.preventDefault();
e.stopPropagation();
}
setDraftValues((prev) =>
prev.includes(val) ? prev.filter((v) => v !== val) : [...prev, val]
);
};
const handleApply = () => {
const handleApply = (e?: React.MouseEvent) => {
if (e) {
e.preventDefault();
e.stopPropagation();
}
onChange?.(draftValues);
setIsOpen(false);
};
const handleClear = () => {
const handleClear = (e?: React.MouseEvent) => {
if (e) {
e.preventDefault();
e.stopPropagation();
}
setDraftValues([]);
};
@ -374,7 +438,7 @@ export function MultiSelect({
</div>
{/* Portaled Dropdown Menu Overlay */}
{isOpen && !disabled && createPortal(
{isOpen && !disabled && Boolean(dropdownStyle.position) && createPortal(
<div
ref={dropdownRef}
style={dropdownStyle}
@ -395,7 +459,11 @@ export function MultiSelect({
{searchQuery && (
<button
type="button"
onClick={() => setSearchQuery('')}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setSearchQuery('');
}}
className="text-muted hover:text-strong p-0.5 rounded cursor-pointer"
>
<X size={12} />
@ -411,9 +479,10 @@ export function MultiSelect({
return (
<div
key={opt.value}
onClick={() => toggleOption(opt.value)}
onClick={(e) => toggleOption(opt.value, e)}
title={opt.label}
className={cn(
"px-3 py-2 text-xs flex items-center gap-2.5 cursor-pointer transition-colors select-none",
"px-3 py-2 text-xs flex items-start gap-2.5 cursor-pointer transition-colors select-none",
isSelected ? "bg-blue-50/70 text-blue-800 font-semibold" : "hover:bg-black/5 text-strong"
)}
>
@ -421,9 +490,9 @@ export function MultiSelect({
type="checkbox"
checked={isSelected}
onChange={() => {}}
className="rounded border-slate-300 text-blue-600 focus:ring-blue-500 pointer-events-none h-3.5 w-3.5"
className="rounded border-slate-300 text-blue-600 focus:ring-blue-500 pointer-events-none h-3.5 w-3.5 mt-0.5 shrink-0"
/>
<span className="truncate flex-1">{opt.label}</span>
<span className="flex-1 break-words text-xs leading-normal">{opt.label}</span>
</div>
);
})

View File

@ -10,7 +10,7 @@ import { Spinner } from '../reusable/Spinner';
import { EmptyState } from '../reusable/EmptyState';
import { StatsTiles } from '../reusable/StatsTiles';
import { AnalyticsChart } from '../reusable/AnalyticsChart';
import { Select, MultiSelect } from '../reusable/Select';
import { MultiSelect } from '../reusable/Select';
export interface RecordViewProps {
/** Workflow-bound client (see api/clients.ts). */

View File

@ -0,0 +1,24 @@
import { userClient } from '../../api/clients';
import { USER } from '../../api/config';
import { RecordView } from './RecordView';
import type { WiredRecordViewProps } from './OrdersView';
/** Users / Sales Officers record view (User workflow). */
export function UsersView({ onRowClick, pageSize, headerActions, rowActions, refreshKey, initialFilters, presetAlias }: WiredRecordViewProps) {
return (
<RecordView
client={userClient}
rvUid={USER.recordViews.SALES_OFFICERS}
title="Sales Officers"
onRowClick={onRowClick}
pageSize={pageSize}
headerActions={headerActions}
rowActions={rowActions}
refreshKey={refreshKey}
sortBy="instance_id"
sortDir="desc"
initialFilters={initialFilters}
presetAlias={presetAlias}
/>
);
}

View File

@ -5,3 +5,4 @@ export type { WiredRecordViewProps } from './OrdersView';
export { CallsView } from './CallsView';
export { StoresView } from './StoresView';
export { DailyLogsView } from './DailyLogsView';
export { UsersView } from './UsersView';

113
src/routesConfig.tsx Normal file
View File

@ -0,0 +1,113 @@
import { OrdersPage } from './screens/OrdersPage'
import { MyOrdersPage } from './screens/MyOrdersPage'
import { CallsPage } from './screens/CallsPage'
import { MyCallsPage } from './screens/MyCallsPage'
import { StoresPage } from './screens/StoresPage'
import { DailyLogsPage } from './screens/DailyLogsPage'
import { MyDailyLogsPage } from './screens/MyDailyLogsPage'
import { DailySalesReportPage } from './screens/DailySalesReportPage'
import { ReportPage } from './screens/ReportPage'
import { DatasetsPage } from './screens/admin/DatasetsPage'
import { UsersPage } from './screens/admin/UsersPage'
export const routeConfig = [
// Sales Officer
{
path: "/my-orders",
element: <MyOrdersPage />,
roles: ["Sales Officer"],
},
{
path: "/my-orders/:instanceId",
element: <MyOrdersPage />,
roles: ["Sales Officer"],
},
{
path: "/my-calls",
element: <MyCallsPage />,
roles: ["Sales Officer"],
},
{
path: "/my-calls/:instanceId",
element: <MyCallsPage />,
roles: ["Sales Officer"],
},
{
path: "/my-daily",
element: <MyDailyLogsPage />,
roles: ["Sales Officer"],
},
{
path: "/my-daily/:instanceId",
element: <MyDailyLogsPage />,
roles: ["Sales Officer"],
},
// Manager & Admin
{
path: "/orders",
element: <OrdersPage />,
roles: ["Manager", "Admin"],
},
{
path: "/orders/:instanceId",
element: <OrdersPage />,
roles: ["Manager", "Admin"],
},
{
path: "/calls",
element: <CallsPage />,
roles: ["Manager", "Admin"],
},
{
path: "/calls/:instanceId",
element: <CallsPage />,
roles: ["Manager", "Admin"],
},
{
path: "/daily",
element: <DailyLogsPage />,
roles: ["Manager", "Admin"],
},
{
path: "/daily/:instanceId",
element: <DailyLogsPage />,
roles: ["Manager", "Admin"],
},
// Everyone
{
path: "/stores",
element: <StoresPage />,
roles: ["Sales Officer", "Manager", "Admin"],
},
{
path: "/stores/:instanceId",
element: <StoresPage />,
roles: ["Sales Officer", "Manager", "Admin"],
},
{
path: "/reports/:reportType",
element: <ReportPage />,
roles: ["Manager", "Admin"],
},
// Manager + Admin
{
path: "/sales-report",
element: <DailySalesReportPage />,
roles: ["Manager", "Admin", "Sales Officer"],
},
// Admin Panel
{
path: "/admin/datasets",
element: <DatasetsPage />,
adminOnly: true,
},
{
path: "/admin/users",
element: <UsersPage />,
adminOnly: true,
},
];

View File

@ -5,7 +5,7 @@ import { CallDetail } from '../components/dv';
import { useEffect, useState } from 'react';
import { Button } from '../components/buttons/Button';
import { Plus } from 'lucide-react';
import { DynamicForm } from '../components/forms/DynamicForm';
import { DynamicForm, LogVisitForm } from '../components/forms';
import { ORDER_BOOKING } from '../api/config';
import { orderBookingClient } from '../api/clients';
@ -185,10 +185,8 @@ export function CallsPage() {
title={createTitle}
width="md"
>
<DynamicForm
<LogVisitForm
client={orderBookingClient}
activityId={ORDER_BOOKING.activities.LOG_VISIT.uid}
initialActivityName="Log Visit"
onSuccess={() => {
setIsCreating(false);
setRefreshKey(k => k + 1);

View File

@ -1,17 +1,30 @@
import { useEffect } from 'react';
import { NavLink, Navigate, Outlet, useNavigate, useLocation } from 'react-router-dom';
import { LogOut, ChevronDown, Database } from 'lucide-react';
import { LogOut, ChevronDown, Database, Users } from 'lucide-react';
import { cn } from '../lib/cn';
import { useAuth } from '../auth/context';
import { onAuthErrorAll, orderBookingClient } from '../api/clients';
import { SCREENS } from './tabs';
import { REPORT_MAP } from './ReportPage';
import { routeConfig } from '../routesConfig';
const PATH_LABELS: Record<string, string> = {
"/my-orders": "My Orders",
"/my-calls": "My Calls",
"/my-daily": "My Daily Logs",
"/orders": "Orders",
"/calls": "Calls",
"/daily": "Daily Logs",
"/stores": "Stores",
"/sales-report": "DSR",
};
/** Auth-guarded shell: navy top bar + tab nav + routed <Outlet>. */
export function ConsoleLayout() {
const { authed, logout } = useAuth();
const { authed, logout, isAdmin, roles: userRoles } = useAuth();
const navigate = useNavigate();
const location = useLocation();
const user = orderBookingClient.currentUser();
@ -28,18 +41,35 @@ export function ConsoleLayout() {
if (!authed) return <Navigate to="/login" replace />;
const reportRoute = routeConfig.find(r => r.path === '/reports/:reportType');
const canSeeReports = reportRoute && (
(reportRoute.adminOnly && isAdmin) ||
(reportRoute.roles && reportRoute.roles.some(r => userRoles.includes(r))) ||
(!reportRoute.adminOnly && !reportRoute.roles) // open to all
);
return (
<div className="h-screen bg-[var(--secondary-color)] flex flex-col">
<header className="sticky top-0 z-10 shrink-0 flex items-center justify-between gap-6 px-6 h-[56px] shadow-sm" style={{ background: 'var(--nav-bg-color)' }}>
<header className="sticky top-0 z-50 shrink-0 flex items-center justify-between gap-6 px-6 h-[56px] shadow-sm" style={{ background: 'var(--nav-bg-color)' }}>
<div className="flex items-center min-w-max w-48">
<span className="text-lg font-bold text-white tracking-wide leading-none">Krishna Sales</span>
</div>
<nav className="flex items-center justify-end gap-2 h-full flex-1 mr-4">
{SCREENS.map((t) => {
{routeConfig.filter(route => {
if (route.path.includes('/:')) return false; // Skip detail pages
if (!PATH_LABELS[route.path]) return false; // Skip unmapped routes
if (route.adminOnly && !isAdmin) return false;
if (!route.adminOnly && route.roles) {
return route.roles.some(r => userRoles.includes(r));
}
return true;
}).map((route) => {
const key = route.path.replace('/', '');
return (
<NavLink
key={t.key}
to={`/${t.key}`}
key={key}
to={route.path}
className={({ isActive }) =>
cn(
'flex items-center gap-1.5 no-underline font-sans text-[13px] font-semibold px-3 py-1.5 rounded-md transition-all duration-150',
@ -47,38 +77,39 @@ export function ConsoleLayout() {
)
}
>
{t.label}
{PATH_LABELS[route.path]}
</NavLink>
);
})}
<div className="relative group flex items-center h-full">
<button className={cn(
"flex items-center gap-1.5 no-underline font-sans text-[13px] font-semibold px-3 py-1.5 rounded-md transition-all duration-150 cursor-pointer",
isReportsActive ? "bg-white/20 text-white" : "text-white/90 hover:bg-white/10 hover:text-white"
)}>
Reports
<ChevronDown size={14} className="ml-0.5 opacity-70" />
</button>
{canSeeReports && (
<div className="relative group flex items-center h-full">
<button className={cn(
"flex items-center gap-1.5 no-underline font-sans text-[13px] font-semibold px-3 py-1.5 rounded-md transition-all duration-150 cursor-pointer",
isReportsActive ? "bg-white/20 text-white" : "text-white/90 hover:bg-white/10 hover:text-white"
)}>
Reports
<ChevronDown size={14} className="ml-0.5 opacity-70" />
</button>
<div className="absolute top-[80%] right-0 mt-1 w-56 bg-white rounded-md shadow-lg py-1 border border-gray-200 hidden group-hover:block z-50">
{Object.entries(REPORT_MAP).map(([key, report]) => (
<NavLink
key={key}
to={`/reports/${key}`}
className={({ isActive }) =>
cn(
"block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100",
isActive && "bg-gray-100 font-semibold"
)
}
>
{report.title}
</NavLink>
))}
<div className="absolute top-[80%] right-0 mt-1 w-56 bg-white rounded-md shadow-lg py-1 border border-gray-200 hidden group-hover:block z-50">
{Object.entries(REPORT_MAP).map(([key, report]) => (
<NavLink
key={key}
to={`/reports/${key}`}
className={({ isActive }) =>
cn(
"block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100",
isActive && "bg-gray-100 font-semibold"
)
}
>
{report.title}
</NavLink>
))}
</div>
</div>
</div>
)}
</nav>
<div className="relative group flex items-center justify-end shrink-0 w-48 h-full py-2">
<button
@ -92,15 +123,24 @@ export function ConsoleLayout() {
<p className="text-sm font-bold text-gray-900 truncate">{user?.name || 'User'}</p>
<p className="text-xs text-gray-500 truncate mt-0.5">{user?.email || 'user@example.com'}</p>
</div>
<div className="px-2 pb-1 border-b border-gray-100 mb-1">
<NavLink
to="/admin/datasets"
className={({ isActive }) => cn("w-full text-left px-3 py-2 text-sm font-medium hover:bg-indigo-50 hover:text-indigo-700 rounded-md transition-colors flex items-center gap-2 cursor-pointer", isActive ? "text-indigo-700 bg-indigo-50" : "text-gray-700")}
>
<Database size={16} />
Manage Datasets
</NavLink>
</div>
{isAdmin && (
<div className="px-2 pb-1 border-b border-gray-100 mb-1">
<NavLink
to="/admin/datasets"
className={({ isActive }) => cn("w-full text-left px-3 py-2 text-sm font-medium hover:bg-indigo-50 hover:text-indigo-700 rounded-md transition-colors flex items-center gap-2 cursor-pointer", isActive ? "text-indigo-700 bg-indigo-50" : "text-gray-700")}
>
<Database size={16} />
Manage Datasets
</NavLink>
<NavLink
to="/admin/users"
className={({ isActive }) => cn("w-full text-left px-3 py-2 text-sm font-medium hover:bg-indigo-50 hover:text-indigo-700 rounded-md transition-colors flex items-center gap-2 cursor-pointer mt-1", isActive ? "text-indigo-700 bg-indigo-50" : "text-gray-700")}
>
<Users size={16} />
Manage Users
</NavLink>
</div>
)}
<div className="px-2">
<button
onClick={() => {

View File

@ -1,10 +1,11 @@
import { useState, type FormEvent } from 'react';
import { useState, useEffect, type FormEvent } from 'react';
import { Card, Input, Select } from '../components/reusable';
import { Button } from '../components/buttons/Button';
import {
SALES_REPORT_ROUTES,
ROUTE_WISE_DISTRIBUTORS,
SALES_REPORT_SO_NAMES
BASE_URL,
PIPELINE
} from '../api/config';
import { Download, Printer } from 'lucide-react';
import jsPDF from 'jspdf';
@ -14,12 +15,36 @@ export function DailySalesReportPage() {
const [date, setDate] = useState('');
const [route, setRoute] = useState('');
const [distributor, setDistributor] = useState('');
const [soName, setSoName] = useState('');
const [soEmail, setSoEmail] = useState('');
const [soOptions, setSoOptions] = useState<{value: string, label: string}[]>([]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [reportData, setReportData] = useState<any>(null);
useEffect(() => {
async function fetchSOs() {
try {
const res = await fetch(`${BASE_URL}${PIPELINE.endpoints.salesOfficers}`, {
headers: {
'TemplateID': '194',
'X-Pipeline-Version': 'latest',
'OrgID': '57',
'GroupID': '25',
'Content-Type': 'application/json'
}
});
const data = await res.json();
if (data?.response?.options) {
setSoOptions(data.response.options);
}
} catch (err) {
console.error('Failed to fetch SOs', err);
}
}
fetchSOs();
}, []);
const downloadPDF = () => {
const doc = new jsPDF({ orientation: 'landscape' });
const pageWidth = doc.internal.pageSize.getWidth();
@ -48,7 +73,8 @@ export function DailySalesReportPage() {
doc.setFontSize(10);
doc.text(`Distributor Name : ${distributor}`, 14, 40);
doc.text(`Date : ${date}`, rightX, 40, { align: 'right' });
doc.text(`SO Name : ${soName}`, rightX, 45, { align: 'right' });
const selectedSo = soOptions.find(o => o.value === soEmail);
doc.text(`SO Name : ${selectedSo ? selectedSo.label : soEmail}`, rightX, 45, { align: 'right' });
// Table
autoTable(doc, {
@ -90,7 +116,8 @@ export function DailySalesReportPage() {
doc.setFontSize(10);
doc.text(`Distributor Name : ${distributor}`, 14, 40);
doc.text(`Date : ${date}`, rightX, 40, { align: 'right' });
doc.text(`SO Name : ${soName}`, rightX, 45, { align: 'right' });
const selectedSo = soOptions.find(o => o.value === soEmail);
doc.text(`SO Name : ${selectedSo ? selectedSo.label : soEmail}`, rightX, 45, { align: 'right' });
// Table
autoTable(doc, {
@ -124,7 +151,7 @@ export function DailySalesReportPage() {
setError(null);
setReportData(null);
try {
const res = await fetch('https://sandbox.getzino.in/api/papi2/daily-sales-report', {
const res = await fetch(`${BASE_URL}${PIPELINE.endpoints.dailySalesReport}`, {
method: 'POST',
headers: {
'TemplateID': '157',
@ -135,7 +162,7 @@ export function DailySalesReportPage() {
date,
route,
distributor,
so_name: soName,
so_name: soEmail,
})
});
@ -181,9 +208,9 @@ export function DailySalesReportPage() {
/>
<Select
label="SO Name"
value={soName}
onChange={(e) => setSoName(e.target.value)}
options={[{ value: '', label: 'Select SO Name' }, ...SALES_REPORT_SO_NAMES.map(s => ({ value: s, label: s }))]}
value={soEmail}
onChange={(e) => setSoEmail(e.target.value)}
options={[{ value: '', label: 'Select SO Name' }, ...soOptions]}
/>
{error && <div className="text-xs text-ruby-600 font-medium">{error}</div>}
@ -211,7 +238,7 @@ export function DailySalesReportPage() {
<div>Distributor Name : {distributor}</div>
<div className="text-right flex flex-col gap-1.5">
<div>Date : {date}</div>
<div>SO Name : {soName}</div>
<div>SO Name : {soOptions.find(o => o.value === soEmail)?.label || soEmail}</div>
</div>
</div>

View File

@ -5,7 +5,7 @@ import { CallDetail } from '../components/dv';
import { useEffect, useState } from 'react';
import { Button } from '../components/buttons/Button';
import { Plus } from 'lucide-react';
import { DynamicForm } from '../components/forms/DynamicForm';
import { DynamicForm, LogVisitForm } from '../components/forms';
import { ORDER_BOOKING } from '../api/config';
import { orderBookingClient } from '../api/clients';
import { useAuth } from '../auth/context';
@ -190,10 +190,8 @@ export function MyCallsPage() {
title={createTitle}
width="md"
>
<DynamicForm
<LogVisitForm
client={orderBookingClient}
activityId={ORDER_BOOKING.activities.LOG_VISIT.uid}
initialActivityName="Log Visit"
onSuccess={() => {
setIsCreating(false);
setRefreshKey(k => k + 1);

View File

@ -5,8 +5,7 @@ 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 { LogVisitForm } from '../components/forms';
import { orderBookingClient } from '../api/clients';
import { useAuth } from '../auth/context';
@ -46,10 +45,8 @@ export function MyOrdersPage() {
title={createTitle}
width="md"
>
<DynamicForm
<LogVisitForm
client={orderBookingClient}
activityId={ORDER_BOOKING.activities.LOG_VISIT.uid}
initialActivityName="Place Order"
onSuccess={() => {
setIsCreating(false);
setRefreshKey(k => k + 1);

View File

@ -5,8 +5,7 @@ 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 { LogVisitForm } from '../components/forms';
import { orderBookingClient } from '../api/clients';
export function OrdersPage() {
@ -40,10 +39,8 @@ export function OrdersPage() {
title={createTitle}
width="md"
>
<DynamicForm
<LogVisitForm
client={orderBookingClient}
activityId={ORDER_BOOKING.activities.LOG_VISIT.uid}
initialActivityName="Place Order"
onSuccess={() => {
setIsCreating(false);
setRefreshKey(k => k + 1);

View File

@ -1,14 +1,13 @@
import { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useParams } from 'react-router-dom';
import { orderBookingClient } from '../../api/clients';
import type { Dataset } from '../../api/types';
import { Button } from '../../components/buttons/Button';
import { Spinner } from '../../components/reusable/Spinner';
import { ArrowLeft, Save, Trash2, Plus, Database, Upload } from 'lucide-react';
import { Save, Trash2, Plus, Database, Upload } from 'lucide-react';
export function DatasetItemsPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [dataset, setDataset] = useState<Dataset | null>(null);
const [loading, setLoading] = useState(true);

View File

@ -2,21 +2,29 @@ import { useEffect, useState } from 'react';
import { orderBookingClient } from '../../api/clients';
import type { Dataset } from '../../api/types';
import { Spinner } from '../../components/reusable/Spinner';
import { Database, LayoutList, Table2 } from 'lucide-react';
import { useNavigate, useLocation, Outlet, useParams } from 'react-router-dom';
import { Database } from 'lucide-react';
import { useNavigate, useLocation, Outlet, useParams, Navigate } from 'react-router-dom';
import { useAuth } from '../../auth/context';
export function DatasetsPage() {
const navigate = useNavigate();
const location = useLocation();
const { id } = useParams<{ id: string }>();
const { isAdmin } = useAuth();
const [datasets, setDatasets] = useState<Dataset[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
fetchDatasets();
}, []);
if (isAdmin) {
fetchDatasets();
}
}, [isAdmin]);
if (!isAdmin) {
return <Navigate to="/orders" replace />;
}
const fetchDatasets = async () => {
try {

View File

@ -0,0 +1,64 @@
import { useState } from 'react';
import { Modal } from '../../components/reusable';
import { UsersView } from '../../components/rv';
import { DynamicForm } from '../../components/forms/DynamicForm';
import { USER } from '../../api/config';
import { userClient } from '../../api/clients';
import { Button } from '../../components/buttons/Button';
import { Plus, Edit } from 'lucide-react';
export function UsersPage() {
const [isCreating, setIsCreating] = useState(false);
const [refreshKey, setRefreshKey] = useState(0);
const [activeActivity, setActiveActivity] = useState<{ id: string; name: string, instanceId?: number } | null>(null);
const handleEditRow = (row: any) => {
setActiveActivity({
id: USER.activities.EDIT_USER.uid,
name: "Edit User",
instanceId: row.instance_id
});
};
return (
<>
<UsersView
refreshKey={refreshKey}
headerActions={
<Button size="sm" iconLeft={<Plus size={14} />} onClick={() => setIsCreating(true)}>
Add Sales Officer
</Button>
}
rowActions={(row) => (
<Button size="sm" variant="outline" iconLeft={<Edit size={14} />} onClick={() => handleEditRow(row)}>
Edit
</Button>
)}
/>
<Modal
open={isCreating || activeActivity !== null}
onClose={() => {
setIsCreating(false);
setActiveActivity(null);
}}
title={activeActivity?.name || "Add User"}
>
<DynamicForm
client={userClient}
activityId={activeActivity?.id || USER.activities.ADD_USER.uid}
instanceId={activeActivity?.instanceId}
onSuccess={() => {
setIsCreating(false);
setActiveActivity(null);
setRefreshKey(k => k + 1);
}}
onCancel={() => {
setIsCreating(false);
setActiveActivity(null);
}}
/>
</Modal>
</>
);
}

View File

@ -13,6 +13,7 @@ import {
DailyLogDetail,
type WiredDetailViewProps,
} from '../components/dv';
import { routeConfig } from '../routesConfig';
export type ScreenKey = 'orders' | 'my-orders' | 'calls' | 'my-calls' | 'stores' | 'daily' | 'my-daily' | 'sales-report';
@ -24,17 +25,22 @@ export interface ScreenDef {
Detail: (p: WiredDetailViewProps) => React.JSX.Element;
/** Singular noun for the detail title. */
noun: string;
roles?: string[];
adminOnly?: boolean;
}
const getRoles = (path: string) => routeConfig.find(r => r.path === `/${path}`)?.roles;
const getAdminOnly = (path: string) => routeConfig.find(r => r.path === `/${path}`)?.adminOnly;
export const SCREENS: ScreenDef[] = [
{ key: 'orders', label: 'Orders', icon: ShoppingCart, View: OrdersView, Detail: OrderDetail, noun: 'Order' },
{ key: 'my-orders', label: 'My Orders', icon: ShoppingCart, View: OrdersView, Detail: OrderDetail, noun: 'My Order' },
{ key: 'calls', label: 'Calls', icon: Phone, View: CallsView, Detail: CallDetail, noun: 'Call' },
{ key: 'my-calls', label: 'My Calls', icon: Phone, View: CallsView, Detail: CallDetail, noun: 'My 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' },
{ key: 'my-daily', label: 'My Daily Logs', icon: ClipboardList, View: DailyLogsView, Detail: DailyLogDetail, noun: 'My Daily Log' },
{ key: 'sales-report', label: 'DSR', icon: FileText, View: null as any, Detail: null as any, noun: 'Sales Report' },
{ key: 'orders', label: 'Orders', icon: ShoppingCart, View: OrdersView, Detail: OrderDetail, noun: 'Order', roles: getRoles('orders'), adminOnly: getAdminOnly('orders') },
{ key: 'my-orders', label: 'My Orders', icon: ShoppingCart, View: OrdersView, Detail: OrderDetail, noun: 'My Order', roles: getRoles('my-orders'), adminOnly: getAdminOnly('my-orders') },
{ key: 'calls', label: 'Calls', icon: Phone, View: CallsView, Detail: CallDetail, noun: 'Call', roles: getRoles('calls'), adminOnly: getAdminOnly('calls') },
{ key: 'my-calls', label: 'My Calls', icon: Phone, View: CallsView, Detail: CallDetail, noun: 'My Call', roles: getRoles('my-calls'), adminOnly: getAdminOnly('my-calls') },
{ key: 'stores', label: 'Stores', icon: Store, View: StoresView, Detail: StoreDetail, noun: 'Store', roles: getRoles('stores'), adminOnly: getAdminOnly('stores') },
{ key: 'daily', label: 'Daily Logs', icon: ClipboardList, View: DailyLogsView, Detail: DailyLogDetail, noun: 'Daily Log', roles: getRoles('daily'), adminOnly: getAdminOnly('daily') },
{ key: 'my-daily', label: 'My Daily Logs', icon: ClipboardList, View: DailyLogsView, Detail: DailyLogDetail, noun: 'My Daily Log', roles: getRoles('my-daily'), adminOnly: getAdminOnly('my-daily') },
{ key: 'sales-report', label: 'DSR', icon: FileText, View: null as any, Detail: null as any, noun: 'Sales Report', roles: getRoles('sales-report'), adminOnly: getAdminOnly('sales-report') },
];
export function screenByKey(key: string | undefined): ScreenDef | undefined {