Compare commits
10 Commits
634e43a6c9
...
eaf0ee3d63
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eaf0ee3d63 | ||
|
|
8640629005 | ||
|
|
8f69af24ba | ||
|
|
dc4a119a7a | ||
|
|
f9ea463ea8 | ||
|
|
b6f2872703 | ||
|
|
d98044692f | ||
|
|
30339b0443 | ||
|
|
0ab9be85a6 | ||
|
|
8ad60001fe |
71
src/App.tsx
71
src/App.tsx
@ -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>
|
||||
|
||||
@ -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 ?? [],
|
||||
};
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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'
|
||||
}
|
||||
};
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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');
|
||||
},
|
||||
};
|
||||
|
||||
63
src/auth/ProtectedRoute.tsx
Normal file
63
src/auth/ProtectedRoute.tsx
Normal 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}</>;
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
555
src/components/forms/LogVisitForm.tsx
Normal file
555
src/components/forms/LogVisitForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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)}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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)}
|
||||
|
||||
2
src/components/forms/index.ts
Normal file
2
src/components/forms/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './DynamicForm';
|
||||
export * from './LogVisitForm';
|
||||
@ -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>
|
||||
);
|
||||
})
|
||||
|
||||
@ -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). */
|
||||
|
||||
24
src/components/rv/UsersView.tsx
Normal file
24
src/components/rv/UsersView.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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
113
src/routesConfig.tsx
Normal 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,
|
||||
},
|
||||
];
|
||||
@ -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);
|
||||
|
||||
@ -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={() => {
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 {
|
||||
|
||||
64
src/screens/admin/UsersPage.tsx
Normal file
64
src/screens/admin/UsersPage.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user